Repository: scipp/plopp

Issue:
Allow other ways of handling masks?
Currently, input data that contains masks will still see the masked values plotted on figure.
Masked values are distinguished from other values by either adding a black edge color around the point markers (1d plots), or by using a different colormap (2d plots).

However, sometimes a user really does not want to see the masked values, for example if the masked values are orders of magnitude larger than the part of the data they were interested in.

Example:
```Py
da = pp.data.data1d()
da.values[0] = 1000.
da.masks['large'] = da.data > sc.scalar(1.0, unit=da.unit)
pp.plot(da)
```
![Figure 17](https://github.com/scipp/plopp/assets/39047984/643d16a0-211e-4b20-a85d-6afe60b59fbc)

The fluctuations in the data cannot be seen because of the spike in the first data point, making the plot rather useless.

Maybe we can simply have an option, e.g. `drop_masked=True` to remove those points?
The question now is: should the default be `drop_masked=True` or `drop_masked=False`?

(Matplotlib has `drop_masked=True` by default for numpy masked arrays). 

Discussion:

Patch:
diff --git a/docs/gallery/scatter3d-with-slider.ipynb b/docs/gallery/scatter3d-with-slider.ipynb
index ef1c4cb..eaffbfa 100644
--- a/docs/gallery/scatter3d-with-slider.ipynb
+++ b/docs/gallery/scatter3d-with-slider.ipynb
@@ -93,6 +93,10 @@
     "slice_node = pw.slice_dims(data_array=da, slices=slider_node)\n",
     "\n",
     "fig = pp.figure3d(slice_node, pixel_size=0.3)\n",
+    "\n",
+    "# Set slider in the middle so panel isn't all dark\n",
+    "slider.controls['time']['slider'].value = 23\n",
+    "\n",
     "pp.widgets.Box([fig, slider])"
    ]
   },
diff --git a/docs/user-guide/customization/custom-interfaces.ipynb b/docs/user-guide/customization/custom-interfaces.ipynb
index 62d4cd8..9fdd613 100644
--- a/docs/user-guide/customization/custom-interfaces.ipynb
+++ b/docs/user-guide/customization/custom-interfaces.ipynb
@@ -398,7 +398,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "da.masks['below_300'] = da.data < sc.scalar(300.0, unit='K')\n",
+    "da.masks['close_to_300'] = abs(da.data - sc.scalar(300.0, unit='K')) < sc.scalar(1.0, unit='K')\n",
     "da.masks['large_x'] = da.coords['x'] > sc.scalar(150.0, unit='m')\n",
     "da"
    ]
diff --git a/docs/user-guide/customization/tweaking-figures.ipynb b/docs/user-guide/customization/tweaking-figures.ipynb
index 5769818..219741b 100644
--- a/docs/user-guide/customization/tweaking-figures.ipynb
+++ b/docs/user-guide/customization/tweaking-figures.ipynb
@@ -114,7 +114,7 @@
   },
   {
    "cell_type": "markdown",
-   "id": "be10729a-08d0-4aec-8556-c6688c2c8386",
+   "id": "a9a8ca49-7df0-4647-96af-8e709c328834",
    "metadata": {
     "editable": true,
     "slideshow": {
@@ -123,15 +123,15 @@
     "tags": []
    },
    "source": [
-    "### Setting the axis range\n",
+    "### Adding a figure title\n",
     "\n",
-    "Changing the range of an axis can be done by setting the `xrange` and `yrange` properties:"
+    "To add a title to the figure, use the `title` argument:"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "3bd58017-a1fe-4046-a760-3ee36e1b0f3d",
+   "id": "e7f8016d-1b89-4786-914c-6d45e76c91a1",
    "metadata": {
     "editable": true,
     "slideshow": {
@@ -141,14 +141,12 @@
    },
    "outputs": [],
    "source": [
-    "p.canvas.xrange = [10, 40]\n",
-    "p.canvas.yrange = [-0.75, 1.5]\n",
-    "p"
+    "pp.plot(da, title='This is my figure title')"
    ]
   },
   {
    "cell_type": "markdown",
-   "id": "a9a8ca49-7df0-4647-96af-8e709c328834",
+   "id": "c7080577-245c-43d1-97ac-1cd677963883",
    "metadata": {
     "editable": true,
     "slideshow": {
@@ -157,15 +155,47 @@
     "tags": []
    },
    "source": [
-    "### Adding a figure title\n",
+    "### Setting the axis range\n",
     "\n",
-    "To add a title to the figure, use the `title` argument:"
+    "#### Vertical axis\n",
+    "\n",
+    "Changing the range of the vertical axis is done using the `vmin` and `vmax` arguments\n",
+    "(note that if only one of the two is given, then the other will be automatically determined from the data plotted)."
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "e7f8016d-1b89-4786-914c-6d45e76c91a1",
+   "id": "448bcedf-b92d-445e-8883-04395c63f1a8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pp.plot(da, vmin=sc.scalar(-0.5, unit='m/s'), vmax=sc.scalar(1.5, unit='m/s'))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f2931238-731f-4986-80ea-2dfe98bd16f1",
+   "metadata": {
+    "editable": true,
+    "slideshow": {
+     "slide_type": ""
+    },
+    "tags": []
+   },
+   "source": [
+    "Note that if the unit in the supplied limits is not identical to the data units, an on-the-fly conversion is attempted.\n",
+    "It is also possible to omit the units altogether, in which case it is assumed the unit is the same as the data unit.\n",
+    "\n",
+    "#### Horizontal axis\n",
+    "\n",
+    "The easiest way to set the range of the horizontal axis is to slice the data before plotting:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3bd58017-a1fe-4046-a760-3ee36e1b0f3d",
    "metadata": {
     "editable": true,
     "slideshow": {
@@ -175,7 +205,29 @@
    },
    "outputs": [],
    "source": [
-    "pp.plot(da, title='This is my figure title')"
+    "pp.plot(da[10:40])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ca82699b-b800-48f6-ab35-b9379c4ec654",
+   "metadata": {},
+   "source": [
+    "Note that this will add some padding around the plotted data points.\n",
+    "\n",
+    "If you wish to have precise control over the limits, you can use the lower-level `canvas.xrange` property:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "575940d9-767c-431f-a9d9-29d6df8ec5e5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p = da.plot()\n",
+    "p.canvas.xrange = [10, 40]\n",
+    "p"
    ]
   },
   {
@@ -189,7 +241,10 @@
     "tags": []
    },
    "source": [
+    "Note that `camvas.yrange` is also available, and is equivalent to using the `vmin` and `vmax` arguments.\n",
+    "\n",
     "## Further modifications\n",
+    "\n",
     "Instead of providing keyword arguments for tweaking every aspect of the figures,\n",
     "we provide accessors to the underlying Matplotlib `Figure` and `Axes` objects,\n",
     "that can then directly be used to make the required modifications."
@@ -223,6 +278,7 @@
    },
    "outputs": [],
    "source": [
+    "p = da.plot()\n",
     "p.ax.set_yticks([-1.0, -0.5, 0, 0.5, 1.0])\n",
     "p.ax.set_yticklabels([r'$-\\pi$', r'$-\\pi / 2$', '0', r'$\\pi / 2$', r'$\\pi$'])\n",
     "p"
@@ -253,12 +309,12 @@
     "ax0 = fig.add_axes([0.0, 0.2, 1.0, 0.8])\n",
     "ax1 = fig.add_axes([0.0, 0.0, 1.0, 0.2])\n",
     "\n",
-    "pp.plot({'a': a, 'b': b}, ax=ax0)\n",
-    "pp.plot(a - b, ax=ax1)\n",
-    "\n",
     "ax0.xaxis.tick_top()\n",
     "ax0.xaxis.set_label_position('top')\n",
-    "ax1.set_ylabel('Residuals')"
+    "ax1.set_ylabel('Residuals')\n",
+    "\n",
+    "p1 = pp.plot({'a': a, 'b': b}, ax=ax0)\n",
+    "p2 = pp.plot(a - b, ax=ax1)"
    ]
   },
   {
@@ -301,8 +357,7 @@
    "mimetype": "text/x-python",
    "name": "python",
    "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.8.17"
+   "pygments_lexer": "ipython3"
   }
  },
  "nbformat": 4,
diff --git a/docs/user-guide/plot-types/image-plot.ipynb b/docs/user-guide/plot-types/image-plot.ipynb
index 6968163..0e5e75a 100644
--- a/docs/user-guide/plot-types/image-plot.ipynb
+++ b/docs/user-guide/plot-types/image-plot.ipynb
@@ -24,7 +24,9 @@
    "id": "8487626a-2461-40c2-b0cd-42c6f82c9fab",
    "metadata": {},
    "source": [
-    "## Basic image plot"
+    "## Basic image plot\n",
+    "\n",
+    "As with one-dimensional data, plotting two-dimensional data is done using the [plot](../../reference/generated/plopp.plot.rst) function."
    ]
   },
   {
@@ -182,7 +184,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "da.masks['negative'] = da.data < sc.scalar(0, unit='m/s')\n",
+    "da.masks['yband'] = abs(da.coords['y'] - sc.scalar(20, unit='m')) < sc.scalar(5, unit='m')\n",
     "da.plot()"
    ]
   },
diff --git a/docs/user-guide/plot-types/line-plot.ipynb b/docs/user-guide/plot-types/line-plot.ipynb
index 2b994cd..bed700e 100644
--- a/docs/user-guide/plot-types/line-plot.ipynb
+++ b/docs/user-guide/plot-types/line-plot.ipynb
@@ -24,7 +24,11 @@
    "id": "8487626a-2461-40c2-b0cd-42c6f82c9fab",
    "metadata": {},
    "source": [
-    "## Basic line plot"
+    "## Basic line plot\n",
+    "\n",
+    "The most common way to plot data with Plopp is to use the [plot](../../reference/generated/plopp.plot.rst) function.\n",
+    "This can either be done using the `plopp.plot()` free function,\n",
+    "or calling the `.plot()` method on a Scipp data object (both are equivalent)."
    ]
   },
   {
@@ -160,7 +164,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "pp.plot(da['x', 10:40])"
+    "da['x', 10:40].plot()"
    ]
   },
   {
@@ -170,8 +174,9 @@
    "source": [
     "## Overplotting\n",
     "\n",
-    "The plot function will accept a dict of data arrays as an input,\n",
-    "and will place all entries on the same axes."
+    "The `plot` function will accept a dict of data arrays as an input,\n",
+    "and will place all entries on the same axes\n",
+    "(as long as all entries have the same units and dimension labels)."
    ]
   },
   {
@@ -287,7 +292,7 @@
    "outputs": [],
    "source": [
     "da = pp.data.data1d()\n",
-    "da.masks['negative'] = da.data < sc.scalar(0, unit='m/s')\n",
+    "da.masks['large-x'] = da.coords['x'] > sc.scalar(30, unit='m')\n",
     "pp.plot(da)"
    ]
   },
@@ -307,7 +312,7 @@
    "outputs": [],
    "source": [
     "da = pp.data.histogram1d()\n",
-    "da.masks['negative'] = da.data < sc.scalar(0, unit='m/s')\n",
+    "da.masks['close-to-zero'] = abs(da.data) < sc.scalar(0.5, unit='m/s')\n",
     "pp.plot(da)"
    ]
   },
diff --git a/src/plopp/backends/common.py b/src/plopp/backends/common.py
new file mode 100644
index 0000000..9fa6b33
--- /dev/null
+++ b/src/plopp/backends/common.py
@@ -0,0 +1,116 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Literal, Optional, Tuple
+
+import numpy as np
+import scipp as sc
+
+from ..core.limits import find_limits, fix_empty_range
+from ..core.utils import merge_masks
+
+
+def _none_min(*args: float) -> float:
+    return min(x for x in args if x is not None)
+
+
+def _none_max(*args: float) -> float:
+    return max(x for x in args if x is not None)
+
+
+@dataclass
+class BoundingBox:
+    """
+    A bounding box in 2D space.
+    """
+
+    xmin: Optional[float] = None
+    xmax: Optional[float] = None
+    ymin: Optional[float] = None
+    ymax: Optional[float] = None
+
+    def union(self, other: BoundingBox) -> BoundingBox:
+        """
+        Return the union of this bounding box with another one.
+        """
+        return BoundingBox(
+            xmin=_none_min(self.xmin, other.xmin),
+            xmax=_none_max(self.xmax, other.xmax),
+            ymin=_none_min(self.ymin, other.ymin),
+            ymax=_none_max(self.ymax, other.ymax),
+        )
+
+
+def axis_bounds(
+    keys: Tuple[str, str],
+    x: sc.DataArray,
+    scale: Literal['linear', 'log'],
+    pad=False,
+) -> Dict[str, float]:
+    """
+    Find sensible limits for an axis, depending on linear or log scale.
+
+    Parameters
+    ----------
+    keys:
+        The keys to use for constructing a bounding box. The keys should be
+        ``('xmin', 'xmax')`` for the horizontal axis, and ``('ymin', 'ymax')`` for the
+        vertical axis.
+    x:
+        The data array to find limits for.
+    scale:
+        The scale of the axis (linear or log).
+    pad:
+        Whether to pad the limits.
+    """
+    values = fix_empty_range(find_limits(x, scale=scale, pad=pad))
+    bounds = {k: v for k, v in zip(keys, (val.value for val in values))}
+    return bounds
+
+
+def make_line_data(data: sc.DataArray, dim: str) -> dict:
+    """
+    Prepare data for plotting a line.
+    This includes extracting the x and y values, and optionally the error bars and masks
+    from the data array.
+    This also handles the case where the data array is a histogram, in which case the
+    first bin edge is repeated.
+
+    Parameters
+    ----------
+    data:
+        The data array to extract values from.
+    dim:
+        The dimension along which to extract values.
+    """
+    x = data.coords[dim]
+    y = data.data
+    hist = len(x) != len(y)
+    error = None
+    mask = {'x': x.values, 'y': np.full(y.shape, np.nan), 'visible': False}
+    if data.variances is not None:
+        error = {
+            'x': sc.midpoints(x).values if hist else x.values,
+            'y': y.values,
+            'e': sc.stddevs(y).values,
+        }
+    if len(data.masks):
+        one_mask = merge_masks(data.masks).values
+        mask = {
+            'x': x.values,
+            'y': np.where(one_mask, y.values, np.nan),
+            'visible': True,
+        }
+    if hist:
+        y = sc.concat([y[0:1], y], dim=dim)
+        if mask is not None:
+            mask['y'] = np.concatenate([mask['y'][0:1], mask['y']])
+    return {
+        'values': {'x': x.values, 'y': y.values},
+        'stddevs': error,
+        'mask': mask,
+        'hist': hist,
+    }
diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py
index c1ad687..0c14ed9 100644
--- a/src/plopp/backends/matplotlib/canvas.py
+++ b/src/plopp/backends/matplotlib/canvas.py
@@ -9,12 +9,14 @@ import scipp as sc
 from matplotlib.collections import QuadMesh
 from mpl_toolkits.axes_grid1 import make_axes_locatable
 
-from ...core.limits import find_limits, fix_empty_range
 from ...core.utils import maybe_variable_to_number, scalar_to_string
+from ..common import BoundingBox, axis_bounds
 from .utils import fig_to_bytes, is_sphinx_build, make_figure
 
 
-def _none_if_not_finite(x):
+def _none_if_not_finite(x: Union[float, int, None]) -> Union[float, int, None]:
+    if x is None:
+        return None
     return x if np.isfinite(x) else None
 
 
@@ -112,11 +114,7 @@ class Canvas:
         self.ax.set_title(title)
         self.ax.grid(grid)
         self._coord_formatters = []
-
-        self._xmin = np.inf
-        self._xmax = np.NINF
-        self._ymin = np.inf
-        self._ymax = np.NINF
+        self._bbox = BoundingBox()
 
     def is_widget(self):
         return hasattr(self.fig.canvas, "on_widget_constructed")
@@ -137,67 +135,68 @@ class Canvas:
 
     def autoscale(self):
         """
-        Matplotlib's autoscale only takes lines into account. We require a special
-        handling for meshes, which is part of the axes collections.
+        Find the limits of the artists on the canvas and adjust the axes ranges.
+        Add some padding in the case of 1d lines.
+        """
+        bbox = BoundingBox()
+        lines = [line for line in self.ax.lines if hasattr(line, '_plopp_mask')]
+        for line in lines:
+            line_mask = sc.array(dims=['x'], values=line._plopp_mask)
+            line_x = sc.DataArray(data=sc.array(dims=['x'], values=line.get_xdata()))
+            line_y = sc.DataArray(
+                data=sc.array(dims=['x'], values=line.get_ydata()),
+                masks={'mask': line_mask},
+            )
+            line_bbox = BoundingBox(
+                **{**axis_bounds(('xmin', 'xmax'), line_x, self.xscale, pad=True)},
+                **{**axis_bounds(('ymin', 'ymax'), line_y, self.yscale, pad=True)},
+            )
+            bbox = bbox.union(line_bbox)
 
-        Parameters
-        ----------
-        draw:
-            Make a draw call to the figure if ``True``.
-        """
-        if self.ax.lines:
-            self.ax.relim()
-            self.ax.autoscale()
-            xmin, xmax = self.ax.get_xlim()
-            ymin, ymax = self.ax.get_ylim()
-        else:
-            xmin = np.inf
-            xmax = np.NINF
-            ymin = np.inf
-            ymax = np.NINF
         for c in self.ax.collections:
             if isinstance(c, QuadMesh):
                 coords = c.get_coordinates()
-                left, right = fix_empty_range(
-                    find_limits(
-                        sc.array(dims=['x', 'y'], values=coords[..., 0]),
-                        scale=self.xscale,
-                    )
+                mesh_x = sc.DataArray(
+                    data=sc.array(dims=['x', 'y'], values=coords[..., 0])
                 )
-                bottom, top = fix_empty_range(
-                    find_limits(
-                        sc.array(dims=['x', 'y'], values=coords[..., 1]),
-                        scale=self.yscale,
-                    )
+                mesh_y = sc.DataArray(
+                    data=sc.array(dims=['x', 'y'], values=coords[..., 1])
                 )
-                xmin = min(xmin, left.value)
-                xmax = max(xmax, right.value)
-                ymin = min(ymin, bottom.value)
-                ymax = max(ymax, top.value)
-        if self._autoscale == 'grow':
-            self._xmin = min(self._xmin, xmin)
-            self._xmax = max(self._xmax, xmax)
-            self._ymin = min(self._ymin, ymin)
-            self._ymax = max(self._ymax, ymax)
-        else:
-            self._xmin = xmin
-            self._xmax = xmax
-            self._ymin = ymin
-            self._ymax = ymax
+                mesh_bbox = BoundingBox(
+                    **{**axis_bounds(('xmin', 'xmax'), mesh_x, self.xscale)},
+                    **{**axis_bounds(('ymin', 'ymax'), mesh_y, self.yscale)},
+                )
+                bbox = bbox.union(mesh_bbox)
+
+            elif hasattr(c, '_plopp_mask'):
+                line_mask = sc.array(dims=['x'], values=c._plopp_mask)
+                line_y = sc.DataArray(
+                    data=sc.array(
+                        dims=['x', 'y'],
+                        values=np.array(c.get_segments())[..., 1],
+                    ),
+                    masks={'mask': line_mask},
+                )
+                line_bbox = BoundingBox(
+                    **axis_bounds(('ymin', 'ymax'), line_y, self.yscale, pad=True)
+                )
+                bbox = bbox.union(line_bbox)
+
+        self._bbox = self._bbox.union(bbox) if self._autoscale == 'grow' else bbox
         if self._user_vmin is not None:
-            self._ymin = maybe_variable_to_number(
+            self._bbox.ymin = maybe_variable_to_number(
                 self._user_vmin, unit=self.units.get('y')
             )
         if self._user_vmax is not None:
-            self._ymax = maybe_variable_to_number(
+            self._bbox.ymax = maybe_variable_to_number(
                 self._user_vmax, unit=self.units.get('y')
             )
 
         self.ax.set_xlim(
-            _none_if_not_finite(self._xmin), _none_if_not_finite(self._xmax)
+            _none_if_not_finite(self._bbox.xmin), _none_if_not_finite(self._bbox.xmax)
         )
         self.ax.set_ylim(
-            _none_if_not_finite(self._ymin), _none_if_not_finite(self._ymax)
+            _none_if_not_finite(self._bbox.ymin), _none_if_not_finite(self._bbox.ymax)
         )
         self.draw()
 
@@ -331,7 +330,7 @@ class Canvas:
         self.cax.set_ylabel(lab)
 
     @property
-    def xscale(self) -> str:
+    def xscale(self) -> Literal['linear', 'log']:
         """
         Get or set the scale of the x-axis ('linear' or 'log').
         """
@@ -342,7 +341,7 @@ class Canvas:
         self.ax.set_xscale(scale)
 
     @property
-    def yscale(self) -> str:
+    def yscale(self) -> Literal['linear', 'log']:
         """
         Get or set the scale of the y-axis ('linear' or 'log').
         """
@@ -419,7 +418,7 @@ class Canvas:
         self.ax.set_ylim(value)
 
     @property
-    def grid(self) -> str:
+    def grid(self) -> bool:
         """
         Get or set the visibility of the grid.
         """
@@ -472,8 +471,8 @@ class Canvas:
         Toggle the scale between ``linear`` and ``log`` along the horizontal axis.
         """
         self.xscale = 'log' if self.xscale == 'linear' else 'linear'
-        self._xmin = np.inf
-        self._xmax = np.NINF
+        self._bbox.xmin = np.inf
+        self._bbox.xmax = np.NINF
         self.autoscale()
 
     def logy(self):
@@ -481,8 +480,8 @@ class Canvas:
         Toggle the scale between ``linear`` and ``log`` along the vertical axis.
         """
         self.yscale = 'log' if self.yscale == 'linear' else 'linear'
-        self._ymin = np.inf
-        self._ymax = np.NINF
+        self._bbox.ymin = np.inf
+        self._bbox.ymax = np.NINF
         self.autoscale()
 
     def finalize(self):
diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py
index 1a6d490..f0b6712 100644
--- a/src/plopp/backends/matplotlib/line.py
+++ b/src/plopp/backends/matplotlib/line.py
@@ -9,7 +9,7 @@ import scipp as sc
 from matplotlib.lines import Line2D
 from numpy.typing import ArrayLike
 
-from ...core.utils import merge_masks
+from ..common import make_line_data
 from .canvas import Canvas
 
 
@@ -54,7 +54,6 @@ class Line:
         self._line = None
         self._mask = None
         self._error = None
-        self._dim = None
         self._unit = None
         self.label = data.name
         self._dim = self._data.dim
@@ -67,7 +66,11 @@ class Line:
             if key in args:
                 args[alias] = args.pop(key)
 
-        self._make_line(data=self._make_data(), number=number, **args)
+        self._make_line(
+            data=make_line_data(data=self._data, dim=self._dim),
+            number=number,
+            **args,
+        )
 
     def _make_line(
         self,
@@ -148,6 +151,9 @@ class Line:
                 visible=data['mask']['visible'],
             )[0]
 
+        line_mask = ~np.isnan(data['mask']['y'])
+        self._line._plopp_mask = line_mask
+
         # Add error bars
         if errorbars and (data['stddevs'] is not None):
             self._error = self._ax.errorbar(
@@ -158,6 +164,8 @@ class Line:
                 zorder=10,
                 fmt="none",
             )
+            # Set the selection mask on the line collection that makes the segments
+            self._error[2][0]._plopp_mask = line_mask[1:] if data["hist"] else line_mask
 
         if self.label and self._canvas._legend:
             leg_args = {}
@@ -170,36 +178,6 @@ class Line:
                 )
             self._ax.legend(**leg_args)
 
-    def _make_data(self) -> dict:
-        x = self._data.coords[self._dim]
-        y = self._data.data
-        hist = len(x) != len(y)
-        error = None
-        mask = {'x': x.values, 'y': y.values, 'visible': False}
-        if self._data.variances is not None:
-            error = {
-                'x': sc.midpoints(x).values if hist else x.values,
-                'y': y.values,
-                'e': sc.stddevs(y).values,
-            }
-        if len(self._data.masks):
-            one_mask = merge_masks(self._data.masks).values
-            mask = {
-                'x': x.values,
-                'y': np.where(one_mask, y.values, np.nan),
-                'visible': True,
-            }
-        if hist:
-            y = sc.concat([y[0:1], y], dim=self._dim)
-            if mask is not None:
-                mask['y'] = np.concatenate([mask['y'][0:1], mask['y']])
-        return {
-            'values': {'x': x.values, 'y': y.values},
-            'stddevs': error,
-            'mask': mask,
-            'hist': hist,
-        }
-
     def update(self, new_values: sc.DataArray):
         """
         Update the x and y positions of the data points from new data.
@@ -210,14 +188,11 @@ class Line:
             New data to update the line values, masks, errorbars from.
         """
         self._data = new_values
-        new_values = self._make_data()
+        new_values = make_line_data(data=self._data, dim=self._dim)
 
         self._line.set_data(new_values['values']['x'], new_values['values']['y'])
         self._mask.set_data(new_values['mask']['x'], new_values['mask']['y'])
-        if new_values['mask']['visible']:
-            self._mask.set_visible(True)
-        else:
-            self._mask.set_visible(False)
+        self._mask.set_visible(new_values['mask']['visible'])
 
         if (self._error is not None) and (new_values['stddevs'] is not None):
             coll = self._error.get_children()[0]
diff --git a/src/plopp/backends/plotly/canvas.py b/src/plopp/backends/plotly/canvas.py
index fd1d33d..18e10bd 100644
--- a/src/plopp/backends/plotly/canvas.py
+++ b/src/plopp/backends/plotly/canvas.py
@@ -3,9 +3,11 @@
 
 from typing import Literal, Tuple, Union
 
+import numpy as np
 import scipp as sc
 
 from ...core.utils import maybe_variable_to_number
+from ..common import BoundingBox, axis_bounds
 
 
 class Canvas:
@@ -28,8 +30,10 @@ class Canvas:
         The maximum value for the vertical axis. If a number (without a unit) is
         supplied, it is assumed that the unit is the same as the current vertical axis
         unit.
-    cbar:
-        Add axes to host a colorbar if ``True``.
+    autoscale:
+        The behavior of the axis limits. 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.
     """
 
     def __init__(
@@ -38,7 +42,7 @@ class Canvas:
         title: str = None,
         vmin: Union[sc.Variable, int, float] = None,
         vmax: Union[sc.Variable, int, float] = None,
-        cbar: bool = False,
+        autoscale: Literal['auto', 'grow'] = 'auto',
         **ignored,
     ):
         # Note on the `**ignored`` keyword arguments: the figure which owns the canvas
@@ -77,8 +81,10 @@ class Canvas:
         self.units = {}
         self.dims = {}
         self._own_axes = False
+        self._autoscale = autoscale
         if title:
             self.title = title
+        self._bbox = BoundingBox()
 
     def to_widget(self):
         return self.fig
@@ -87,22 +93,45 @@ class Canvas:
         """
         Auto-scale the axes ranges to show all data in the canvas.
         """
-        ymin = None
-        ymax = None
-        if (self._user_vmin is not None) or (self._user_vmax is not None):
-            if None in (self._user_vmin, self._user_vmax):
-                raise ValueError(
-                    'With the Plotly backend, you have to specify both '
-                    'vmin and vmax.'
+        bbox = BoundingBox()
+        lines = [trace for trace in self.fig.data if hasattr(trace, '_plopp_mask')]
+        for line in lines:
+            line_mask = sc.array(dims=['x'], values=line._plopp_mask)
+            line_x = sc.DataArray(data=sc.array(dims=['x'], values=line.x))
+            line_y = sc.array(dims=['x'], values=line.y)
+            if line.error_y.array is not None:
+                line_y = sc.concat(
+                    [
+                        line_y,
+                        sc.array(dims=['x'], values=line.y + line.error_y.array),
+                        sc.array(dims=['x'], values=line.y - line.error_y.array),
+                    ],
+                    dim='y',
                 )
-            ymin = maybe_variable_to_number(self._user_vmin, unit=self.yunit)
-            ymax = maybe_variable_to_number(self._user_vmax, unit=self.yunit)
-            self.fig.update_layout(
-                yaxis={'autorange': False}, xaxis={'autorange': True}
+            line_y = sc.DataArray(data=line_y, masks={'mask': line_mask})
+            line_bbox = BoundingBox(
+                **{**axis_bounds(('xmin', 'xmax'), line_x, self.xscale, pad=True)},
+                **{**axis_bounds(('ymin', 'ymax'), line_y, self.yscale, pad=True)},
             )
-            self.fig.update_yaxes(range=[ymin, ymax])
-        else:
-            self.fig.update_layout(yaxis={'autorange': True}, xaxis={'autorange': True})
+            bbox = bbox.union(line_bbox)
+
+        self._bbox = self._bbox.union(bbox) if self._autoscale == 'grow' else bbox
+        if self._user_vmin is not None:
+            self._bbox.ymin = maybe_variable_to_number(
+                self._user_vmin, unit=self.units.get('y')
+            )
+        if self._user_vmax is not None:
+            self._bbox.ymax = maybe_variable_to_number(
+                self._user_vmax, unit=self.units.get('y')
+            )
+
+        xrange = np.array([self._bbox.xmin, self._bbox.xmax])
+        yrange = np.array([self._bbox.ymin, self._bbox.ymax])
+        if self.xscale == 'log':
+            xrange = np.log10(xrange)
+        if self.yscale == 'log':
+            yrange = np.log10(yrange)
+        self.fig.update_layout(xaxis_range=xrange, yaxis_range=yrange)
 
     def save(self, filename: str):
         """
@@ -182,22 +211,22 @@ class Canvas:
         self.fig.layout.yaxis.title = lab
 
     @property
-    def xscale(self) -> str:
+    def xscale(self) -> Literal['linear', 'log']:
         """
         Get or set the scale of the x-axis ('linear' or 'log').
         """
-        return self.fig.layout.xaxis.type
+        return self.fig.layout.xaxis.type or 'linear'
 
     @xscale.setter
     def xscale(self, scale: Literal['linear', 'log']):
         self.fig.update_xaxes(type=scale)
 
     @property
-    def yscale(self) -> str:
+    def yscale(self) -> Literal['linear', 'log']:
         """
         Get or set the scale of the y-axis ('linear' or 'log').
         """
-        return self.fig.layout.yaxis.type
+        return self.fig.layout.yaxis.type or 'linear'
 
     @yscale.setter
     def yscale(self, scale: Literal['linear', 'log']):
diff --git a/src/plopp/backends/plotly/line.py b/src/plopp/backends/plotly/line.py
index ede894f..30df85b 100644
--- a/src/plopp/backends/plotly/line.py
+++ b/src/plopp/backends/plotly/line.py
@@ -7,7 +7,7 @@ from typing import Dict
 import numpy as np
 import scipp as sc
 
-from ...core.utils import merge_masks
+from ..common import make_line_data
 from .canvas import Canvas
 
 
@@ -48,7 +48,6 @@ class Line:
         self._line = None
         self._mask = None
         self._error = None
-        self._dim = None
         self._unit = None
         self.label = data.name
         self._dim = self._data.dim
@@ -56,7 +55,11 @@ class Line:
         self._coord = self._data.coords[self._dim]
         self._id = uuid.uuid4().hex
 
-        self._make_line(data=self._make_data(), number=number, **args)
+        self._make_line(
+            data=make_line_data(data=self._data, dim=self._dim),
+            number=number,
+            **args,
+        )
 
     def _make_line(
         self,
@@ -172,39 +175,12 @@ class Line:
             self._fig.add_trace(self._mask)
             self._mask = self._fig.data[-1]
         self._line._plopp_id = self._id
+        line_mask = ~np.isnan(data['mask']['y'])
+        self._line._plopp_mask = line_mask
         self._mask._plopp_id = self._id
         if self._error is not None:
             self._error._plopp_id = self._id
-
-    def _make_data(self) -> dict:
-        x = self._data.coords[self._dim]
-        y = self._data.data
-        hist = len(x) != len(y)
-        error = None
-        mask = {'x': x.values, 'y': y.values, 'visible': False}
-        if self._data.variances is not None:
-            error = {
-                'x': sc.midpoints(x).values if hist else x.values,
-                'y': y.values,
-                'e': sc.stddevs(y).values,
-            }
-        if len(self._data.masks):
-            one_mask = merge_masks(self._data.masks).values
-            mask = {
-                'x': x.values,
-                'y': np.where(one_mask, y.values, np.nan),
-                'visible': True,
-            }
-        if hist:
-            y = sc.concat([y[0:1], y], dim=self._dim)
-            if mask is not None:
-                mask['y'] = np.concatenate([mask['y'][0:1], mask['y']])
-        return {
-            'values': {'x': x.values, 'y': y.values},
-            'stddevs': error,
-            'mask': mask,
-            'hist': hist,
-        }
+            self._error._plopp_mask = line_mask[1:] if data["hist"] else line_mask
 
     def update(self, new_values: sc.DataArray):
         """
@@ -216,7 +192,7 @@ class Line:
             New data to update the line values, masks, errorbars from.
         """
         self._data = new_values
-        new_values = self._make_data()
+        new_values = make_line_data(data=self._data, dim=self._dim)
 
         with self._fig.batch_update():
             self._line.update(
diff --git a/src/plopp/core/limits.py b/src/plopp/core/limits.py
index 25dfbbc..2cca4b1 100644
--- a/src/plopp/core/limits.py
+++ b/src/plopp/core/limits.py
@@ -4,18 +4,34 @@
 from typing import Literal, Tuple
 
 import numpy as np
-from scipp import Variable, scalar
+import scipp as sc
+
+from .utils import merge_masks
 
 
 def find_limits(
-    x: Variable, scale: Literal['linear', 'log'] = 'linear'
-) -> Tuple[Variable, ...]:
+    x: sc.DataArray,
+    scale: Literal['linear', 'log'] = 'linear',
+    pad: bool = False,
+) -> Tuple[sc.Variable, sc.Variable]:
     """
     Find sensible limits, depending on linear or log scale.
     If there are no finite values in the array, raise an error.
     If there are no positive values in the array, and the scale is log, fall back to
     some sensible default values.
     """
+    is_datetime = x.dtype == sc.DType.datetime64
+    # Computing limits for string arrays is not supported, so we convert them to
+    # dummy numerical arrays.
+    if x.dtype == sc.DType.string:
+        x = x.copy(deep=False)
+        x.data = sc.arange(x.dim, float(len(x)), unit=x.unit)
+    if x.masks:
+        x = sc.where(
+            merge_masks(x.masks),
+            sc.scalar(np.NaN, unit=x.unit),
+            x.data.to(dtype='float64'),
+        )
     v = x.values
     finite_inds = np.isfinite(v)
     if np.sum(finite_inds) == 0:
@@ -23,29 +39,46 @@ def find_limits(
     finite_vals = v[finite_inds]
     finite_max = None
     if scale == "log":
-        positives = finite_vals > 0
-        if np.sum(positives) == 0:
-            finite_min = 0.1
-            finite_max = 1.0
+        if is_datetime:
+            finite_min = np.amin(finite_vals)
         else:
-            initial = (np.finfo if v.dtype.kind == 'f' else np.iinfo)(v.dtype).max
-            finite_min = np.amin(finite_vals, initial=initial, where=finite_vals > 0)
+            positives = finite_vals > 0
+            if np.sum(positives) == 0:
+                finite_min = 0.1
+                finite_max = 1.0
+            else:
+                initial = (np.finfo if v.dtype.kind == 'f' else np.iinfo)(v.dtype).max
+                finite_min = np.amin(
+                    finite_vals, initial=initial, where=finite_vals > 0
+                )
     else:
         finite_min = np.amin(finite_vals)
     if finite_max is None:
         finite_max = np.amax(finite_vals)
-    return (scalar(finite_min, unit=x.unit), scalar(finite_max, unit=x.unit))
+    if pad:
+        delta = 0.05
+        if (scale == 'log') and (not is_datetime):
+            p = (finite_max / finite_min) ** delta
+            finite_min /= p
+            finite_max *= p
+        if scale == 'linear':
+            p = (finite_max - finite_min) * delta
+            finite_min -= p
+            finite_max += p
+    return (sc.scalar(finite_min, unit=x.unit), sc.scalar(finite_max, unit=x.unit))
 
 
-def fix_empty_range(lims: Tuple[Variable, Variable]) -> Tuple[Variable, Variable]:
+def fix_empty_range(
+    lims: Tuple[sc.Variable, sc.Variable]
+) -> Tuple[sc.Variable, sc.Variable]:
     """
     Range correction in case xmin == xmax
     """
     if lims[0].value != lims[1].value:
         return lims
     if lims[0].value == 0.0:
-        dx = scalar(0.5, unit=lims[0].unit)
+        dx = sc.scalar(0.5, unit=lims[0].unit)
     else:
         # We decompose value and unit to avoid operation exceptions when unit is None.
-        dx = scalar(0.5 * abs(lims[0].value), unit=lims[0].unit)
+        dx = sc.scalar(0.5 * abs(lims[0].value), unit=lims[0].unit)
     return (lims[0] - dx, lims[1] + dx)
diff --git a/src/plopp/graphics/lineview.py b/src/plopp/graphics/lineview.py
index 03ed6b3..07ffd25 100644
--- a/src/plopp/graphics/lineview.py
+++ b/src/plopp/graphics/lineview.py
@@ -104,7 +104,6 @@ class LineView(View):
         self.canvas.yscale = norm
 
         self.render()
-        self.canvas.autoscale()
         self.canvas.finalize()
 
     def update(self, new_values: sc.DataArray, key: str):


Test patch:
diff --git a/tests/core/limits_test.py b/tests/core/limits_test.py
index 7a48e00..0d9519e 100644
--- a/tests/core/limits_test.py
+++ b/tests/core/limits_test.py
@@ -9,72 +9,124 @@ from plopp.core.limits import find_limits, fix_empty_range
 
 
 def test_find_limits():
-    x = sc.arange('x', 11.0, unit='m')
-    lims = find_limits(x)
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    lims = find_limits(da)
     assert sc.identical(lims[0], sc.scalar(0.0, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
 
 
 def test_find_limits_log():
-    x = sc.arange('x', 11.0, unit='m')
-    lims = find_limits(x, scale='log')
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    lims = find_limits(da, scale='log')
     assert sc.identical(lims[0], sc.scalar(1.0, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
 
 
 def test_find_limits_log_int():
-    x = sc.arange('x', 11, unit='m')
-    lims = find_limits(x, scale='log')
+    da = sc.DataArray(data=sc.arange('x', 11, unit='m'))
+    lims = find_limits(da, scale='log')
     assert sc.identical(lims[0], sc.scalar(1, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10, unit='m'))
 
 
 def test_find_limits_with_nan():
-    x = sc.arange('x', 11.0, unit='m')
-    x.values[5] = np.nan
-    lims = find_limits(x)
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    da.values[5] = np.nan
+    lims = find_limits(da)
     assert sc.identical(lims[0], sc.scalar(0.0, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
 
 
 def test_find_limits_with_inf():
-    x = sc.arange('x', 11.0, unit='m')
-    x.values[5] = np.inf
-    lims = find_limits(x)
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    da.values[5] = np.inf
+    lims = find_limits(da)
     assert sc.identical(lims[0], sc.scalar(0.0, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
 
 
 def test_find_limits_with_ninf():
-    x = sc.arange('x', 11.0, unit='m')
-    x.values[5] = np.NINF
-    lims = find_limits(x)
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    da.values[5] = np.NINF
+    lims = find_limits(da)
     assert sc.identical(lims[0], sc.scalar(0.0, unit='m'))
     assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
 
 
 def test_find_limits_no_finite_values_raises():
-    x = sc.array(dims=['x'], values=[np.nan, np.inf, np.NINF, np.nan], unit='m')
+    da = sc.DataArray(
+        data=sc.array(dims=['x'], values=[np.nan, np.inf, np.NINF, np.nan], unit='m')
+    )
     with pytest.raises(ValueError, match="No finite values were found in array"):
-        _ = find_limits(x)
+        _ = find_limits(da)
 
 
 def test_find_limits_all_zeros():
-    x = sc.zeros(sizes={'x': 5}, unit='s')
-    lims = find_limits(x)
+    da = sc.DataArray(data=sc.zeros(sizes={'x': 5}, unit='s'))
+    lims = find_limits(da)
     assert sc.identical(lims[0], sc.scalar(0.0, unit='s'))
     assert sc.identical(lims[1], sc.scalar(0.0, unit='s'))
 
 
 def test_find_limits_all_zeros_log_uses_default_positive_values():
-    x = sc.zeros(sizes={'x': 5}, unit='s')
-    lims = find_limits(x, scale='log')
+    da = sc.DataArray(data=sc.zeros(sizes={'x': 5}, unit='s'))
+    lims = find_limits(da, scale='log')
     zero = sc.scalar(0.0, unit='s')
     assert lims[0] > zero
     assert lims[1] > zero
     assert lims[0] < lims[1]
 
 
+def test_find_limits_ignores_masks():
+    x = sc.arange('x', 11.0, unit='m')
+    da = sc.DataArray(data=x, masks={'mask': x > sc.scalar(5.0, unit='m')})
+    lims = find_limits(da)
+    assert sc.identical(lims[0], sc.scalar(0.0, unit='m'))
+    assert sc.identical(lims[1], sc.scalar(5.0, unit='m'))
+
+
+def test_find_limits_ignores_masks_log():
+    x = sc.arange('x', 11.0, unit='m')
+    da = sc.DataArray(data=x, masks={'mask': x < sc.scalar(3.0, unit='m')})
+    lims = find_limits(da)
+    assert sc.identical(lims[0], sc.scalar(3.0, unit='m'))
+    assert sc.identical(lims[1], sc.scalar(10.0, unit='m'))
+
+
+def test_find_limits_with_padding():
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    lims = find_limits(da, pad=True)
+    assert lims[0] < sc.scalar(0.0, unit='m')
+    assert lims[1] > sc.scalar(10.0, unit='m')
+
+
+def test_find_limits_with_padding_log():
+    da = sc.DataArray(data=sc.arange('x', 11.0, unit='m'))
+    lims = find_limits(da, scale='log', pad=True)
+    assert lims[0] < sc.scalar(1.0, unit='m')
+    assert lims[0] > sc.scalar(0.0, unit='m')
+    assert lims[1] > sc.scalar(10.0, unit='m')
+
+
+def test_find_limits_with_strings():
+    da = sc.DataArray(
+        data=sc.array(dims=['x'], values=['a', 'b', 'c', 'd', 'e'], unit='K')
+    )
+    lims = find_limits(da)
+    assert sc.identical(lims[0], sc.scalar(0.0, unit='K'))
+    assert sc.identical(lims[1], sc.scalar(4.0, unit='K'))
+
+
+def test_find_limits_datetime():
+    time = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    da = sc.DataArray(data=sc.array(dims=['time'], values=time))
+    lims = find_limits(da)
+    assert sc.identical(lims[0], sc.scalar(time[0], unit='s'))
+    assert sc.identical(lims[1], sc.scalar(time[-1], unit='s'))
+
+
 def test_fix_empty_range():
     a = sc.scalar(5.0, unit='m')
     b = sc.scalar(10.0, unit='m')
diff --git a/tests/plotting/plot_test.py b/tests/plotting/plot_1d_test.py
similarity index 68%
rename from tests/plotting/plot_test.py
rename to tests/plotting/plot_1d_test.py
index 324fef3..dea7714 100644
--- a/tests/plotting/plot_test.py
+++ b/tests/plotting/plot_1d_test.py
@@ -16,7 +16,15 @@ def test_plot_ndarray():
     pp.plot(np.arange(50.0))
 
 
+def test_plot_ndarray_int():
+    pp.plot(np.arange(50))
+
+
 def test_plot_list():
+    pp.plot([1.0, 2.0, 3.0, 4.0, 5.0])
+
+
+def test_plot_list_int():
     pp.plot([1, 2, 3, 4, 5])
 
 
@@ -26,9 +34,6 @@ def test_plot_variable():
 
 def test_plot_data_array():
     pp.plot(data_array(ndim=1))
-    da = data_array(ndim=2)
-    pp.plot(da)
-    pp.plot(da)
 
 
 def test_plot_data_array_missing_coords():
@@ -59,9 +64,8 @@ def test_plot_dict_of_data_arrays():
     pp.plot({'a': ds['a'], 'b': ds['b']})
 
 
-@pytest.mark.parametrize('ndim', [1, 2])
-def test_plot_from_node(ndim):
-    da = data_array(ndim=ndim)
+def test_plot_from_node():
+    da = data_array(ndim=1)
     pp.plot(pp.Node(da))
 
 
@@ -84,12 +88,6 @@ def test_plot_mixing_raw_data_and_nodes():
     pp.plot({'a': pp.Node(a), 'b': b})
 
 
-def test_plot_data_array_2d_with_one_missing_coord_and_binedges():
-    da = sc.data.table_xyz(100).bin(x=10, y=12).bins.mean()
-    del da.coords['x']
-    pp.plot(da)
-
-
 def test_plot_coord_with_no_unit():
     da = sc.data.table_xyz(100).bin(x=10).bins.mean()
     da.coords['x'].unit = None
@@ -114,18 +112,6 @@ def test_plot_with_non_dimensional_unsorted_coord_does_not_warn():
     pp.plot(da)
 
 
-def test_plot_2d_coord():
-    da = data_array(ndim=2, ragged=True)
-    pp.plot(da)
-    pp.plot(da.transpose())
-
-
-def test_plot_2d_coord_with_mask():
-    da = data_array(ndim=2, ragged=True)
-    da.masks['negative'] = da.data < sc.scalar(0, unit='m/s')
-    pp.plot(da)
-
-
 def _get_artist(fig, n=0):
     artists = fig.artists
     keys = list(artists.keys())
@@ -179,19 +165,6 @@ def test_kwarg_scale():
     assert p.canvas.ax.get_yscale() == 'linear'
 
 
-def test_kwarg_cmap():
-    da = data_array(ndim=2)
-    p = pp.plot(da, cmap='magma')
-    assert p._view.colormapper.cmap.name == 'magma'
-
-
-def test_kwarg_scale_2d():
-    da = data_array(ndim=2)
-    p = pp.plot(da, scale={'xx': 'log', 'yy': 'log'})
-    assert p.canvas.ax.get_xscale() == 'log'
-    assert p.canvas.ax.get_yscale() == 'log'
-
-
 def test_kwarg_for_two_lines():
     ds = dataset(ndim=1)
     p = pp.plot(ds, color='r')
@@ -211,7 +184,7 @@ def test_kwarg_as_dict():
 
 
 def test_raises_ValueError_when_given_binned_data():
-    da = sc.data.table_xyz(100).bin(x=10, y=20)
+    da = sc.data.table_xyz(100).bin(x=10)
     with pytest.raises(ValueError, match='Cannot plot binned data'):
         pp.plot(da)
 
@@ -226,23 +199,12 @@ def test_raises_ValueError_when_given_unsupported_data_type():
         pp.plot(nested_dict)
 
 
-def test_use_non_dimension_coords():
-    da = data_array(ndim=2, binedges=True)
-    da.coords['xx2'] = 7.5 * da.coords['xx']
-    da.coords['yy2'] = 3.3 * da.coords['yy']
-    p = pp.plot(da, coords=['xx2', 'yy2'])
-    assert p.canvas.dims['x'] == 'xx2'
-    assert p.canvas.dims['y'] == 'yy2'
-    assert p.canvas._xmax == 7.5 * da.coords['xx'].max().value
-    assert p.canvas._ymax == 3.3 * da.coords['yy'].max().value
-
-
 def test_use_non_dimension_coords_dataset():
     ds = dataset(ndim=1)
     ds.coords['xx2'] = 6.6 * ds.coords['xx']
     p = pp.plot(ds, coords=['xx2'])
     assert p.canvas.dims['x'] == 'xx2'
-    assert p.canvas._xmax > 6.6 * ds.coords['xx'].max().value
+    assert p.canvas.xmax > 6.6 * ds.coords['xx'].max().value
 
 
 @pytest.mark.parametrize('ext', ['jpg', 'png', 'pdf', 'svg'])
@@ -255,30 +217,11 @@ def test_save_to_disk_1d(ext):
         assert os.path.isfile(fname)
 
 
-@pytest.mark.parametrize('ext', ['jpg', 'png', 'pdf', 'svg'])
-def test_save_to_disk_2d(ext):
-    da = data_array(ndim=2)
-    fig = pp.plot(da)
-    with tempfile.TemporaryDirectory() as path:
-        fname = os.path.join(path, f'plopp_fig2d.{ext}')
-        fig.save(filename=fname)
-        assert os.path.isfile(fname)
-
-
 def test_save_to_disk_with_bad_extension_raises():
-    da = data_array(ndim=2)
+    da = data_array(ndim=1)
     fig = pp.plot(da)
     with pytest.raises(ValueError):
-        fig.save(filename='plopp_fig2d.txt')
-
-
-def test_plot_raises_with_multiple_2d_inputs():
-    a = data_array(ndim=2)
-    b = 3.3 * a
-    with pytest.raises(
-        ValueError, match='The plot function can only plot a single 2d data entry'
-    ):
-        pp.plot({'a': a, 'b': b})
+        fig.save(filename='plopp_fig1d.txt')
 
 
 def test_plot_xarray_data_array_1d():
@@ -294,24 +237,6 @@ def test_plot_xarray_data_array_1d():
     assert p.canvas.units['y'] == 'dimensionless'
 
 
-def test_plot_xarray_data_array_2d():
-    import xarray as xr
-
-    N = 50
-    M = 40
-    data = np.random.random([M, N])
-    time = np.arange(float(N))
-    space = np.arange(float(M))
-    da = xr.DataArray(
-        data, coords={'space': space, 'time': time}, dims=['space', 'time']
-    )
-    p = pp.plot(da)
-    assert p.canvas.dims['x'] == 'time'
-    assert p.canvas.dims['y'] == 'space'
-    assert p.canvas.units['x'] == 'dimensionless'
-    assert p.canvas.units['y'] == 'dimensionless'
-
-
 def test_plot_xarray_dataset():
     import xarray as xr
 
@@ -399,3 +324,113 @@ def test_names_are_overridden_when_plotting_dicts(Constructor):
     assert artists[1].label == 'b'
     assert da1.name == 'DA1'
     assert da2.name == 'DA2'
+
+
+def test_plot_1d_ignores_masked_data_for_vertical_range():
+    da = data_array(ndim=1)
+    da.values[10] = 100
+    da.masks['m'] = da.data > sc.scalar(5.0, unit='m/s')
+    p = pp.plot(da)
+    assert p.canvas.ymax < 100
+
+
+def test_plot_1d_includes_masked_data_in_horizontal_range():
+    da = data_array(ndim=1)
+    da.masks['m'] = da.coords['xx'] > sc.scalar(5.0, unit='m')
+    p = pp.plot(da)
+    # If we check only for > 5, padding may invalidate the test
+    assert p.canvas.xmax > 10.0
+
+
+def test_plot_1d_datetime_coord():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'])
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+    )
+    pp.plot(da)
+
+
+def test_plot_1d_datetime_coord_binedges():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'] - 1)
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+    )
+    pp.plot(da)
+
+
+def test_plot_1d_datetime_coord_with_mask():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'])
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+        masks={'m': time > sc.datetime('2017-03-16T21:10:00')},
+    )
+    pp.plot(da)
+
+
+def test_plot_1d_datetime_coord_with_mask_and_binedges():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'] - 1)
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+        masks={'m': sc.midpoints(time) > sc.datetime('2017-03-16T21:10:00')},
+    )
+    pp.plot(da)
+
+
+def test_plot_1d_datetime_coord_log():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'])
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+    )
+    pp.plot(da, scale={'time': 'log'})
+
+
+def test_plot_1d_datetime_coord_log_binedges():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'] - 1)
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+    )
+    pp.plot(da, scale={'time': 'log'})
+
+
+def test_plot_1d_datetime_coord_log_with_mask():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'])
+    da = sc.DataArray(
+        data=sc.array(dims=['time'], values=10 * v, variances=v),
+        coords={'time': time},
+        masks={'m': time > sc.datetime('2017-03-16T21:10:00')},
+    )
+    pp.plot(da, scale={'time': 'log'})
diff --git a/tests/plotting/plot_2d_test.py b/tests/plotting/plot_2d_test.py
new file mode 100644
index 0000000..9b0789a
--- /dev/null
+++ b/tests/plotting/plot_2d_test.py
@@ -0,0 +1,179 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+import os
+import tempfile
+
+import numpy as np
+import pytest
+import scipp as sc
+
+import plopp as pp
+from plopp.data.testing import data_array, variable
+
+
+def test_plot_ndarray():
+    pp.plot(np.arange(50.0).reshape(5, 10))
+
+
+def test_plot_ndarray_int():
+    pp.plot(np.arange(50).reshape(5, 10))
+
+
+def test_plot_variable():
+    pp.plot(variable(ndim=2))
+
+
+def test_plot_data_array():
+    pp.plot(data_array(ndim=2))
+
+
+def test_plot_from_node():
+    da = data_array(ndim=2)
+    pp.plot(pp.Node(da))
+
+
+def test_plot_data_array_2d_with_one_missing_coord_and_binedges():
+    da = sc.data.table_xyz(100).bin(x=10, y=12).bins.mean()
+    del da.coords['x']
+    pp.plot(da)
+
+
+def test_plot_2d_coord():
+    da = data_array(ndim=2, ragged=True)
+    pp.plot(da)
+    pp.plot(da.transpose())
+
+
+def test_plot_2d_coord_with_mask():
+    da = data_array(ndim=2, ragged=True)
+    da.masks['negative'] = da.data < sc.scalar(0, unit='m/s')
+    pp.plot(da)
+
+
+def test_kwarg_cmap():
+    da = data_array(ndim=2)
+    p = pp.plot(da, cmap='magma')
+    assert p._view.colormapper.cmap.name == 'magma'
+
+
+def test_kwarg_scale_2d():
+    da = data_array(ndim=2)
+    p = pp.plot(da, scale={'xx': 'log', 'yy': 'log'})
+    assert p.canvas.ax.get_xscale() == 'log'
+    assert p.canvas.ax.get_yscale() == 'log'
+
+
+def test_raises_ValueError_when_given_binned_data():
+    da = sc.data.table_xyz(100).bin(x=10, y=20)
+    with pytest.raises(ValueError, match='Cannot plot binned data'):
+        pp.plot(da)
+
+
+def test_use_non_dimension_coords():
+    da = data_array(ndim=2, binedges=True)
+    da.coords['xx2'] = 7.5 * da.coords['xx']
+    da.coords['yy2'] = 3.3 * da.coords['yy']
+    p = pp.plot(da, coords=['xx2', 'yy2'])
+    assert p.canvas.dims['x'] == 'xx2'
+    assert p.canvas.dims['y'] == 'yy2'
+    assert p.canvas.xmax == 7.5 * da.coords['xx'].max().value
+    assert p.canvas.ymax == 3.3 * da.coords['yy'].max().value
+
+
+@pytest.mark.parametrize('ext', ['jpg', 'png', 'pdf', 'svg'])
+def test_save_to_disk_2d(ext):
+    da = data_array(ndim=2)
+    fig = pp.plot(da)
+    with tempfile.TemporaryDirectory() as path:
+        fname = os.path.join(path, f'plopp_fig2d.{ext}')
+        fig.save(filename=fname)
+        assert os.path.isfile(fname)
+
+
+def test_save_to_disk_with_bad_extension_raises():
+    da = data_array(ndim=2)
+    fig = pp.plot(da)
+    with pytest.raises(ValueError):
+        fig.save(filename='plopp_fig2d.txt')
+
+
+def test_plot_raises_with_multiple_2d_inputs():
+    a = data_array(ndim=2)
+    b = 3.3 * a
+    with pytest.raises(
+        ValueError, match='The plot function can only plot a single 2d data entry'
+    ):
+        pp.plot({'a': a, 'b': b})
+
+
+def test_plot_xarray_data_array_2d():
+    import xarray as xr
+
+    N = 50
+    M = 40
+    data = np.random.random([M, N])
+    time = np.arange(float(N))
+    space = np.arange(float(M))
+    da = xr.DataArray(
+        data, coords={'space': space, 'time': time}, dims=['space', 'time']
+    )
+    p = pp.plot(da)
+    assert p.canvas.dims['x'] == 'time'
+    assert p.canvas.dims['y'] == 'space'
+    assert p.canvas.units['x'] == 'dimensionless'
+    assert p.canvas.units['y'] == 'dimensionless'
+
+
+def test_plot_2d_ignores_masked_data_for_colorbar_range():
+    da = data_array(ndim=2)
+    da['xx', 10]['yy', 10].values = 100
+    da.masks['m'] = da.data > sc.scalar(5.0, unit='m/s')
+    p = pp.plot(da)
+    assert p._view.colormapper.vmax < 100
+
+
+def test_plot_2d_includes_masked_data_in_horizontal_range():
+    da = data_array(ndim=2)
+    da.masks['left'] = da.coords['xx'] < sc.scalar(5.0, unit='m')
+    da.masks['right'] = da.coords['xx'] > sc.scalar(30.0, unit='m')
+    p = pp.plot(da)
+    assert p.canvas.xmin < 1.0
+    assert p.canvas.xmax > 40.0
+
+
+def test_plot_2d_includes_masked_data_in_vertical_range():
+    da = data_array(ndim=2)
+    da.masks['bottom'] = da.coords['yy'] < sc.scalar(5.0, unit='m')
+    da.masks['top'] = da.coords['yy'] > sc.scalar(20.0, unit='m')
+    p = pp.plot(da)
+    assert p.canvas.ymin < 1.0
+    assert p.canvas.ymax > 30.0
+
+
+def test_plot_2d_datetime_coord():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    z = sc.arange('z', 50.0, unit='m')
+    v = np.random.random(z.shape + time.shape)
+    da = sc.DataArray(
+        data=sc.array(dims=['z', 'time'], values=10 * v, variances=v),
+        coords={'time': time, 'z': z},
+    )
+    pp.plot(da)
+
+
+def test_plot_2d_datetime_coord_binedges():
+    t = np.arange(
+        np.datetime64('2017-03-16T20:58:17'), np.datetime64('2017-03-16T21:15:17'), 20
+    )
+    time = sc.array(dims=['time'], values=t)
+    z = sc.arange('z', 50.0, unit='m')
+    v = np.random.random(z[:-1].shape + time[:-1].shape)
+    da = sc.DataArray(
+        data=sc.array(dims=['z', 'time'], values=10 * v, variances=v),
+        coords={'time': time, 'z': z},
+    )
+    pp.plot(da)