Repository: scipp/plopp

Issue:
Missing cursor hover values in 2d image plots
This is because we are now using `pcolormesh` instead of `imshow`, which used to give the value by default.

We should try to re-enable this, probably using something like https://stackoverflow.com/a/42581480/13086629

We could either have this enabled/disabled by a 'hover tool' in the toolbar, or have it enabled by default, as long as the number of pixels is small (because the searchsorted should be fast enough), and have as an opt-in for larger images.

Discussion:

Patch:
diff --git a/docs/examples/gallery/rectangle-selection.ipynb b/docs/examples/gallery/rectangle-selection.ipynb
index 4baa64c..d885219 100644
--- a/docs/examples/gallery/rectangle-selection.ipynb
+++ b/docs/examples/gallery/rectangle-selection.ipynb
@@ -113,14 +113,14 @@
     "    \"\"\"\n",
     "    return lambda: {\n",
     "        'x': {\n",
-    "            'dim': figure.dims['x'],\n",
-    "            'left': sc.scalar(artist.xy[0], unit=figure.canvas.xunit),\n",
-    "            'right': sc.scalar(artist.xy[0] + artist.width, unit=figure.canvas.xunit),\n",
+    "            'dim': figure.canvas.dims['x'],\n",
+    "            'left': sc.scalar(artist.xy[0], unit=figure.canvas.units['x']),\n",
+    "            'right': sc.scalar(artist.xy[0] + artist.width, unit=figure.canvas.units['x']),\n",
     "        },\n",
     "        'y': {\n",
-    "            'dim': figure.dims['y'],\n",
-    "            'bottom': sc.scalar(artist.xy[1], unit=figure.canvas.yunit),\n",
-    "            'top': sc.scalar(artist.xy[1] + artist.height, unit=figure.canvas.yunit),\n",
+    "            'dim': figure.canvas.dims['y'],\n",
+    "            'bottom': sc.scalar(artist.xy[1], unit=figure.canvas.units['y']),\n",
+    "            'top': sc.scalar(artist.xy[1] + artist.height, unit=figure.canvas.units['y']),\n",
     "        },\n",
     "    }\n",
     "\n",
diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py
index 75a0989..ff01ee6 100644
--- a/src/plopp/backends/matplotlib/canvas.py
+++ b/src/plopp/backends/matplotlib/canvas.py
@@ -10,7 +10,7 @@ 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
+from ...core.utils import maybe_variable_to_number, scalar_to_string
 from .utils import fig_to_bytes, is_sphinx_build, silent_mpl_figure
 
 
@@ -84,8 +84,8 @@ class Canvas:
         self.cax = cax
         self._user_vmin = vmin
         self._user_vmax = vmax
-        self.xunit = None
-        self.yunit = None
+        self.units = {}
+        self.dims = {}
         self._own_axes = False
         self._autoscale = autoscale
 
@@ -108,6 +108,7 @@ class Canvas:
         self.ax.set_aspect(aspect)
         self.ax.set_title(title)
         self.ax.grid(grid)
+        self._coord_formatters = []
 
         self._xmin = np.inf
         self._xmax = np.NINF
@@ -181,9 +182,9 @@ class Canvas:
             self._ymin = ymin
             self._ymax = ymax
         if self._user_vmin is not None:
-            self._ymin = maybe_variable_to_number(self._user_vmin, unit=self.yunit)
+            self._ymin = maybe_variable_to_number(self._user_vmin, unit=self.units['y'])
         if self._user_vmax is not None:
-            self._ymax = maybe_variable_to_number(self._user_vmax, unit=self.yunit)
+            self._ymax = maybe_variable_to_number(self._user_vmax, unit=self.units['y'])
 
         self.ax.set_xlim(
             _none_if_not_finite(self._xmin), _none_if_not_finite(self._xmax)
@@ -231,12 +232,71 @@ class Canvas:
         for xy, lims in limits.items():
             getattr(self.ax, f'set_{xy}lim')(
                 *[
-                    maybe_variable_to_number(lims[m], unit=getattr(self, f'{xy}unit'))
+                    maybe_variable_to_number(lims[m], unit=self.units[xy])
                     for m in ('min', 'max')
                     if m in lims
                 ]
             )
 
+    def set_axes(self, dims, units):
+        """
+        Set the axes dimensions and units.
+
+        Parameters
+        ----------
+        dims:
+            The dimensions of the data.
+        units:
+            The units of the data.
+        """
+        self.units = units
+        self.dims = dims
+        self._cursor_x_placeholder = sc.scalar(0.0, unit=self.units['x'])
+        self._cursor_y_placeholder = sc.scalar(0.0, unit=self.units['y'])
+        self._cursor_x_prefix = ''
+        self._cursor_y_prefix = ''
+        if 'y' in self.dims:
+            self._cursor_x_prefix = self.dims['x'] + '='
+            self._cursor_y_prefix = self.dims['y'] + '='
+        self.ax.format_coord = self.format_coord
+
+    def register_format_coord(self, formatter):
+        """
+        Register a custom axis formatter for the x-axis.
+        """
+        self._coord_formatters.append(formatter)
+
+    def format_coord(self, x: float, y: float) -> str:
+        """
+        Format the coordinates of the mouse pointer to show the value of the
+        data at that point.
+
+        Parameters
+        ----------
+        x:
+            The x coordinate of the mouse pointer.
+        y:
+            The y coordinate of the mouse pointer.
+        """
+        self._cursor_x_placeholder.value = x
+        self._cursor_y_placeholder.value = y
+        out = (
+            f"({self._cursor_x_prefix}{scalar_to_string(self._cursor_x_placeholder)}, "
+            f"{self._cursor_y_prefix}{scalar_to_string(self._cursor_y_placeholder)})"
+        )
+        extra = [formatter(x, y) for formatter in self._coord_formatters]
+        extra = [e for e in extra if e is not None]
+        if extra:
+            out += ": {" + ", ".join(extra) + "}"
+        return out
+
+    @property
+    def empty(self) -> bool:
+        """
+        Check if the canvas is empty.
+        """
+        return not self.dims
+
     @property
     def title(self) -> str:
         """
diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py
index 4d68c73..93f431c 100644
--- a/src/plopp/backends/matplotlib/image.py
+++ b/src/plopp/backends/matplotlib/image.py
@@ -6,7 +6,7 @@ import uuid
 import numpy as np
 import scipp as sc
 
-from ...core.utils import coord_as_bin_edges, merge_masks, repeat
+from ...core.utils import coord_as_bin_edges, merge_masks, repeat, scalar_to_string
 from .canvas import Canvas
 
 
@@ -95,12 +95,14 @@ class Image:
         to_dim_search = {}
         string_labels = {}
         bin_edge_coords = {}
+        self._data_with_bin_edges = sc.DataArray(data=self._data.data)
         for i, k in enumerate('yx'):
             to_dim_search[k] = {
                 'dim': self._data.dims[i],
                 'var': self._data.meta[self._data.dims[i]],
             }
             bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i])
+            self._data_with_bin_edges.coords[self._data.dims[i]] = bin_edge_coords[k]
             if self._data.meta[self._data.dims[i]].dtype == str:
                 string_labels[k] = self._data.meta[self._data.dims[i]]
 
@@ -130,6 +132,31 @@ class Image:
         if need_grid:
             self._ax.grid(True)
 
+        # Cache slicing order for hover values
+        if self._dim_1d is not None:
+            # If there is a 2d coord, we first slice the 1d coord, and then the
+            # dimension that is left should also be 1d, making value-based slicing
+            # possible.
+            self._hover_slicing = {
+                'dir': (self._dim_1d[0], self._dim_2d[0]),
+                'dim': (self._dim_1d[1], self._dim_2d[1]),
+                'unit': (
+                    self._data.coords[self._dim_1d[1]].unit,
+                    self._data.coords[self._dim_2d[1]].unit,
+                ),
+            }
+        else:
+            self._hover_slicing = {
+                'dir': ('y', 'x'),
+                'dim': (self._data.dims[0], self._data.dims[1]),
+                'unit': (
+                    self._data.coords[self._data.dims[0]].unit,
+                    self._data.coords[self._data.dims[1]].unit,
+                ),
+            }
+
+        self._canvas.register_format_coord(self.format_coord)
+
     @property
     def data(self):
         """
@@ -175,3 +202,38 @@ class Image:
             New data to update the mesh values from.
         """
         self._data = new_values
+
+    def format_coord(self, x: float, y: float) -> str:
+        """
+        Format the coordinates of the mouse pointer to show the value of the
+        data at that point.
+
+        Parameters
+        ----------
+        x:
+            The x coordinate of the mouse pointer.
+        y:
+            The y coordinate of the mouse pointer.
+        """
+        xy = {'x': x, 'y': y}
+        try:
+            val = self._data_with_bin_edges[
+                self._hover_slicing['dim'][0],
+                sc.scalar(
+                    xy[self._hover_slicing['dir'][0]],
+                    unit=self._hover_slicing['unit'][0],
+                ),
+            ]
+            val = val[
+                self._hover_slicing['dim'][1],
+                sc.scalar(
+                    xy[self._hover_slicing['dir'][1]],
+                    unit=self._hover_slicing['unit'][1],
+                ),
+            ]
+            prefix = self._data.name
+            if prefix:
+                prefix += ': '
+            return prefix + scalar_to_string(val)
+        except IndexError:
+            return None
diff --git a/src/plopp/backends/matplotlib/interactive.py b/src/plopp/backends/matplotlib/interactive.py
index 1439404..85e6b8b 100644
--- a/src/plopp/backends/matplotlib/interactive.py
+++ b/src/plopp/backends/matplotlib/interactive.py
@@ -58,10 +58,6 @@ class InteractiveFig(VBox):
     def artists(self):
         return self._fig.artists
 
-    @property
-    def dims(self):
-        return self._fig.dims
-
     @property
     def graph_nodes(self):
         return self._fig.graph_nodes
diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py
index a0a8a22..e78e140 100644
--- a/src/plopp/backends/pythreejs/canvas.py
+++ b/src/plopp/backends/pythreejs/canvas.py
@@ -38,9 +38,8 @@ class Canvas:
     ):
         import pythreejs as p3
 
-        self.xunit = None
-        self.yunit = None
-        self.zunit = None
+        self.dims = {}
+        self.units = {}
         self.outline = None
         self.axticks = None
         self.figsize = np.asarray(figsize)
@@ -67,6 +66,20 @@ class Canvas:
     def to_widget(self):
         return self.renderer
 
+    def set_axes(self, dims, units):
+        """
+        Set the axes dimensions and units.
+
+        Parameters
+        ----------
+        dims:
+            The dimensions of the data.
+        units:
+            The units of the data.
+        """
+        self.units = units
+        self.dims = dims
+
     def make_outline(self, limits: Tuple[sc.Variable, sc.Variable, sc.Variable]):
         """
         Create an outline box with ticklabels, given a range in the XYZ directions.
@@ -89,7 +102,9 @@ class Canvas:
         camera via the ``home`` function.
         """
         if not self._user_camera.has_units():
-            self._user_camera.set_units(self.xunit, self.yunit, self.zunit)
+            self._user_camera.set_units(
+                self.units.get('x'), self.units.get('y'), self.units.get('z')
+            )
 
         center = [var.mean().value for var in limits]
         distance_fudge_factor = 1.2
@@ -132,6 +147,13 @@ class Canvas:
             center[2] - distance_fudge_factor * box_mean_size,
         ]
 
+    @property
+    def empty(self) -> bool:
+        """
+        Check if the canvas is empty.
+        """
+        return not self.dims
+
     def home(self):
         """
         Reset the camera position.
diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py
index 84de7e4..f4643f8 100644
--- a/src/plopp/core/utils.py
+++ b/src/plopp/core/utils.py
@@ -159,6 +159,22 @@ def value_to_string(val: Union[int, float], precision: int = 3) -> str:
     return text
 
 
+def scalar_to_string(var: sc.Variable, precision: int = 3) -> str:
+    """
+    Convert a scalar to a human readable string.
+
+    Parameters
+    ----------
+    var:
+        The input scalar.
+    precision:
+        The number of decimal places to use for the string output.
+    """
+    return value_to_string(var.value, precision=precision) + (
+        f" {var.unit}" if var.unit not in (None, "") else ""
+    )
+
+
 def merge_masks(masks: Dict[str, sc.Variable]) -> sc.Variable:
     """
     Combine all masks into a single one using the OR operation.
@@ -181,10 +197,9 @@ def coord_element_to_string(x: sc.Variable) -> str:
     x:
         The input variable (of length 1 or 2).
     """
-    if x.shape:
-        out = ':'.join([value_to_string(v) for v in x.values])
-    else:
-        out = value_to_string(x.value)
+    if not x.shape:
+        return value_to_string(x.value) + (f" [{x.unit}]" if x.unit is not None else "")
+    out = ':'.join([value_to_string(v) for v in x.values])
     if x.unit is not None:
         out += f" [{x.unit}]"
     return out
diff --git a/src/plopp/graphics/basefig.py b/src/plopp/graphics/basefig.py
index d99f4dd..28aa041 100644
--- a/src/plopp/graphics/basefig.py
+++ b/src/plopp/graphics/basefig.py
@@ -22,7 +22,6 @@ class BaseFig(View):
     def __init__(self, *nodes):
         super().__init__(*nodes)
         self.artists = {}
-        self.dims = {}
 
     def notify_view(self, message: Dict[str, Any]):
         """
diff --git a/src/plopp/graphics/figimage.py b/src/plopp/graphics/figimage.py
index b421bf1..a383870 100644
--- a/src/plopp/graphics/figimage.py
+++ b/src/plopp/graphics/figimage.py
@@ -132,37 +132,32 @@ class FigImage(BaseFig):
         xcoord = new_values.coords[xdim]
         ydim = new_values.dims[0]
         ycoord = new_values.coords[ydim]
-        if not self.dims:
-            self.dims.update({"x": xdim, "y": ydim})
-            self.canvas.xunit = xcoord.unit
-            self.canvas.yunit = ycoord.unit
+        if self.canvas.empty:
+            self.canvas.set_axes(
+                dims={'x': xdim, 'y': ydim}, units={'x': xcoord.unit, 'y': ycoord.unit}
+            )
+            self.canvas.xlabel = name_with_unit(var=xcoord)
+            self.canvas.ylabel = name_with_unit(var=ycoord)
             self.colormapper.unit = new_values.unit
+            if xdim in self._scale:
+                self.canvas.xscale = self._scale[xdim]
+            if ydim in self._scale:
+                self.canvas.yscale = self._scale[ydim]
         else:
             new_values.data = make_compatible(
                 new_values.data, unit=self.colormapper.unit
             )
-            new_values.coords[xdim] = make_compatible(
-                xcoord, dim=self.dims['x'], unit=self.canvas.xunit
-            )
-            new_values.coords[ydim] = make_compatible(
-                ycoord, dim=self.dims['y'], unit=self.canvas.yunit
-            )
+            for xyz, dim in {'x': xdim, 'y': ydim}.items():
+                new_values.coords[dim] = make_compatible(
+                    new_values.coords[dim],
+                    dim=self.canvas.dims[xyz],
+                    unit=self.canvas.units[xyz],
+                )
 
         if key not in self.artists:
             image = backends.image(canvas=self.canvas, data=new_values, **self._kwargs)
             self.artists[key] = image
             self.colormapper[key] = image
-            self.dims.update({"x": new_values.dims[1], "y": new_values.dims[0]})
-
-            self.canvas.xunit = new_values.meta[new_values.dims[1]].unit
-            self.canvas.yunit = new_values.meta[new_values.dims[0]].unit
-
-            self.canvas.xlabel = name_with_unit(var=new_values.meta[self.dims['x']])
-            self.canvas.ylabel = name_with_unit(var=new_values.meta[self.dims['y']])
-            if self.dims['x'] in self._scale:
-                self.canvas.xscale = self._scale[self.dims['x']]
-            if self.dims['y'] in self._scale:
-                self.canvas.yscale = self._scale[self.dims['y']]
 
         self.artists[key].update(new_values=new_values)
         self.colormapper.update(key=key, data=new_values)
@@ -176,4 +171,4 @@ class FigImage(BaseFig):
         **limits:
             Min and max limits for each dimension to be cropped.
         """
-        self.canvas.crop(**{xy: limits[self.dims[xy]] for xy in 'xy'})
+        self.canvas.crop(**{xy: limits[self.canvas.dims[xy]] for xy in 'xy'})
diff --git a/src/plopp/graphics/figline.py b/src/plopp/graphics/figline.py
index a81fa6a..c4dca0f 100644
--- a/src/plopp/graphics/figline.py
+++ b/src/plopp/graphics/figline.py
@@ -122,16 +122,22 @@ class FigLine(BaseFig):
         if new_values.ndim != 1:
             raise ValueError("FigLine can only be used to plot 1-D data.")
 
-        dim = new_values.dim
-        coord = new_values.coords[dim]
-        if not self.dims:
-            self.dims['x'] = dim
-            self.canvas.xunit = coord.unit
-            self.canvas.yunit = new_values.unit
+        xdim = new_values.dim
+        xcoord = new_values.coords[xdim]
+        if self.canvas.empty:
+            self.canvas.set_axes(
+                dims={'x': xdim}, units={'x': xcoord.unit, 'y': new_values.unit}
+            )
+            self.canvas.xlabel = name_with_unit(var=xcoord)
+            self.canvas.ylabel = name_with_unit(var=new_values.data, name="")
+            if xdim in self._scale:
+                self.canvas.xscale = self._scale[xdim]
         else:
-            new_values.data = make_compatible(new_values.data, unit=self.canvas.yunit)
-            new_values.coords[dim] = make_compatible(
-                coord, dim=self.dims['x'], unit=self.canvas.xunit
+            new_values.data = make_compatible(
+                new_values.data, unit=self.canvas.units['y']
+            )
+            new_values.coords[xdim] = make_compatible(
+                xcoord, dim=self.canvas.dims['x'], unit=self.canvas.units['x']
             )
 
         if key not in self.artists:
@@ -145,12 +151,6 @@ class FigLine(BaseFig):
             )
             self.artists[key] = line
 
-            self.canvas.xlabel = name_with_unit(var=new_values.meta[self.dims['x']])
-            self.canvas.ylabel = name_with_unit(var=new_values.data, name="")
-
-            if self.dims['x'] in self._scale:
-                self.canvas.xscale = self._scale[self.dims['x']]
-
         else:
             self.artists[key].update(new_values=new_values)
 
@@ -165,4 +165,4 @@ class FigLine(BaseFig):
         **limits:
             Min and max limits for each dimension to be cropped.
         """
-        self.canvas.crop(x=limits[self.dims['x']])
+        self.canvas.crop(x=limits[self.canvas.dims['x']])
diff --git a/src/plopp/graphics/figscatter3d.py b/src/plopp/graphics/figscatter3d.py
index c74c1c5..4bc3e5f 100644
--- a/src/plopp/graphics/figscatter3d.py
+++ b/src/plopp/graphics/figscatter3d.py
@@ -103,28 +103,21 @@ class FigScatter3d(BaseFig):
         draw:
             This argument is ignored for the 3d figure update.
         """
-        xcoord = new_values.coords[self._x]
-        ycoord = new_values.coords[self._y]
-        zcoord = new_values.coords[self._z]
-        if not self.dims:
-            self.dims.update({'x': self._x, 'y': self._y, 'z': self._z})
-            self.canvas.xunit = xcoord.unit
-            self.canvas.yunit = ycoord.unit
-            self.canvas.zunit = zcoord.unit
+        mapping = {'x': self._x, 'y': self._y, 'z': self._z}
+        if self.canvas.empty:
+            self.canvas.set_axes(
+                dims=mapping,
+                units={x: new_values.coords[dim].unit for x, dim in mapping.items()},
+            )
             self.colormapper.unit = new_values.unit
         else:
             new_values.data = make_compatible(
                 new_values.data, unit=self.colormapper.unit
             )
-            new_values.coords[self._x] = new_values.coords[self._x].to(
-                unit=self.canvas.xunit, copy=False
-            )
-            new_values.coords[self._y] = new_values.coords[self._y].to(
-                unit=self.canvas.yunit, copy=False
-            )
-            new_values.coords[self._z] = new_values.coords[self._z].to(
-                unit=self.canvas.zunit, copy=False
-            )
+            for xyz, dim in mapping.items():
+                new_values.coords[dim] = new_values.coords[dim].to(
+                    unit=self.canvas.units[xyz], copy=False
+                )
 
         if key not in self.artists:
             pts = backends.point_cloud(
diff --git a/src/plopp/widgets/drawing.py b/src/plopp/widgets/drawing.py
index daa10e5..8cbca3a 100644
--- a/src/plopp/widgets/drawing.py
+++ b/src/plopp/widgets/drawing.py
@@ -119,12 +119,12 @@ def _get_points_info(artist, figure):
     """
     return lambda: {
         'x': {
-            'dim': figure.dims['x'],
-            'value': sc.scalar(artist.x, unit=figure.canvas.xunit),
+            'dim': figure.canvas.dims['x'],
+            'value': sc.scalar(artist.x, unit=figure.canvas.units['x']),
         },
         'y': {
-            'dim': figure.dims['y'],
-            'value': sc.scalar(artist.y, unit=figure.canvas.yunit),
+            'dim': figure.canvas.dims['y'],
+            'value': sc.scalar(artist.y, unit=figure.canvas.units['y']),
         },
     }
 


Test patch:
diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py
index 8505b71..a857e26 100644
--- a/tests/backends/matplotlib/mpl_canvas_test.py
+++ b/tests/backends/matplotlib/mpl_canvas_test.py
@@ -44,8 +44,7 @@ def test_logy():
 
 def test_crop():
     canvas = Canvas()
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
+    canvas.units.update(x='m', y='m')
     xmin = sc.scalar(2.1, unit='m')
     xmax = sc.scalar(102.0, unit='m')
     ymin = sc.scalar(5.5, unit='m')
@@ -66,8 +65,7 @@ def test_crop():
 
 def test_crop_unit_conversion():
     canvas = Canvas()
-    canvas.xunit = 'cm'
-    canvas.yunit = 'cm'
+    canvas.units.update(x='cm', y='cm')
     xmin = sc.scalar(2.1, unit='m')
     xmax = sc.scalar(3.3, unit='m')
     canvas.crop(x={'min': xmin, 'max': xmax})
@@ -76,8 +74,7 @@ def test_crop_unit_conversion():
 
 def test_crop_no_variable():
     canvas = Canvas()
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
+    canvas.units.update(x='m', y='m')
     xmin = 2.1
     xmax = 102.0
     ymin = 5.5
diff --git a/tests/backends/pythreejs/pythreejs_canvas_test.py b/tests/backends/pythreejs/pythreejs_canvas_test.py
index fd0fff8..587362c 100644
--- a/tests/backends/pythreejs/pythreejs_canvas_test.py
+++ b/tests/backends/pythreejs/pythreejs_canvas_test.py
@@ -115,9 +115,7 @@ def test_camera_user_vector(key):
     pos = (0, 1, 2)
     vec = sc.vector(pos, unit='m')
     canvas = Canvas(camera=Camera(**{key: vec}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     _assert_pos(pos, canvas, key)
 
@@ -126,9 +124,7 @@ def test_camera_user_vector(key):
 def test_camera_user_vector_unit_conversion(key):
     vec = sc.vector([0, 1, 2], unit='cm')
     canvas = Canvas(camera=Camera(**{key: vec}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     _assert_pos((0, 0.01, 0.02), canvas, key)
 
@@ -137,9 +133,7 @@ def test_camera_user_vector_unit_conversion(key):
 def test_camera_user_vector_bad_unit_raises(key):
     vec = sc.vector([0, 1, 2], unit='s')
     canvas = Canvas(camera=Camera(**{key: vec}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     with pytest.raises(sc.UnitError):
         canvas._update_camera(limits=_make_limits())
 
@@ -148,9 +142,7 @@ def test_camera_user_vector_bad_unit_raises(key):
 def test_camera_user_vector_can_convert_a_single_field(key):
     vec = sc.vector([0, 1, 2], unit='m')
     canvas = Canvas(camera=Camera(**{key: vec}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'cm'
+    canvas.units.update(x='m', y='m', z='cm')
     canvas._update_camera(limits=_make_limits())
     _assert_pos((0, 1, 200), canvas, key)
 
@@ -163,9 +155,7 @@ def test_camera_user_tuple_of_variables(key):
         sc.scalar(44.0, unit='m'),
     ]
     canvas = Canvas(camera=Camera(**{key: pos}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     _assert_pos((2.0, -12.0, 44.0), canvas, key)
 
@@ -178,9 +168,7 @@ def test_camera_user_tuple_of_variables_unit_conversion(key):
         sc.scalar(44.0, unit='mm'),
     ]
     canvas = Canvas(camera=Camera(**{key: pos}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     _assert_pos((2.0, -0.12, 0.044), canvas, key)
 
@@ -193,9 +181,7 @@ def test_camera_user_tuple_of_variables_bad_unit_raises(key):
         sc.scalar(44.0, unit='m'),
     ]
     canvas = Canvas(camera=Camera(**{key: pos}))
-    canvas.xunit = 's'
-    canvas.yunit = 's'
-    canvas.zunit = 's'
+    canvas.units.update(x='s', y='s', z='s')
     with pytest.raises(sc.UnitError):
         canvas._update_camera(limits=_make_limits())
 
@@ -212,9 +198,7 @@ def test_camera_user_float(key):
 def test_camera_user_variable(key):
     value = sc.scalar(15.1, unit='m')
     canvas = Canvas(camera=Camera(**{key: value}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     assert getattr(canvas.camera, key) == value.value
 
@@ -223,9 +207,7 @@ def test_camera_user_variable(key):
 def test_camera_user_variable_unit_conversion(key):
     value = sc.scalar(33.0, unit='cm')
     canvas = Canvas(camera=Camera(**{key: value}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     canvas._update_camera(limits=_make_limits())
     assert getattr(canvas.camera, key) == 0.33
 
@@ -234,9 +216,7 @@ def test_camera_user_variable_unit_conversion(key):
 def test_camera_user_variable_bad_unit_raises(key):
     value = sc.scalar(6.6, unit='s')
     canvas = Canvas(camera=Camera(**{key: value}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 'm'
+    canvas.units.update(x='m', y='m', z='m')
     with pytest.raises(sc.UnitError):
         canvas._update_camera(limits=_make_limits())
 
@@ -245,9 +225,7 @@ def test_camera_user_variable_bad_unit_raises(key):
 def test_camera_user_variable_raises_when_axes_units_are_different(key):
     value = sc.scalar(5.66, unit='m')
     canvas = Canvas(camera=Camera(**{key: value}))
-    canvas.xunit = 'm'
-    canvas.yunit = 'm'
-    canvas.zunit = 's'
+    canvas.units.update(x='m', y='m', z='s')
     with pytest.raises(sc.UnitError, match='All axes must have the same unit'):
         canvas._update_camera(limits=_make_limits())
 
diff --git a/tests/functions/plot_test.py b/tests/functions/plot_test.py
index a14adca..66d87bb 100644
--- a/tests/functions/plot_test.py
+++ b/tests/functions/plot_test.py
@@ -248,8 +248,8 @@ def test_use_non_dimension_coords():
     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._fig.dims['x'] == 'xx2'
-    assert p._fig.dims['y'] == '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
 
@@ -258,7 +258,7 @@ 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._fig.dims['x'] == 'xx2'
+    assert p.canvas.dims['x'] == 'xx2'
     assert p.canvas._xmax > 6.6 * ds.coords['xx'].max().value
 
 
@@ -306,9 +306,9 @@ def test_plot_xarray_data_array_1d():
     time = np.arange(float(N))
     da = xr.DataArray(data, coords={'time': time}, dims=['time'])
     p = pp.plot(da)
-    assert p._fig.dims['x'] == 'time'
-    assert p.canvas.xunit == 'dimensionless'
-    assert p.canvas.yunit == 'dimensionless'
+    assert p.canvas.dims['x'] == 'time'
+    assert p.canvas.units['x'] == 'dimensionless'
+    assert p.canvas.units['y'] == 'dimensionless'
 
 
 def test_plot_xarray_data_array_2d():
@@ -323,10 +323,10 @@ def test_plot_xarray_data_array_2d():
         data, coords={'space': space, 'time': time}, dims=['space', 'time']
     )
     p = pp.plot(da)
-    assert p._fig.dims['x'] == 'time'
-    assert p._fig.dims['y'] == 'space'
-    assert p.canvas.xunit == 'dimensionless'
-    assert p.canvas.yunit == 'dimensionless'
+    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():
@@ -343,9 +343,9 @@ def test_plot_xarray_dataset():
         coords={"time": np.arange(50)},
     )
     p = pp.plot(ds)
-    assert p._fig.dims['x'] == 'time'
-    assert p.canvas.xunit == 'dimensionless'
-    assert p.canvas.yunit == 'dimensionless'
+    assert p.canvas.dims['x'] == 'time'
+    assert p.canvas.units['x'] == 'dimensionless'
+    assert p.canvas.units['y'] == 'dimensionless'
     assert len(p._fig.artists) == 2
 
 
@@ -354,9 +354,9 @@ def test_plot_pandas_series():
 
     s = pd.Series(np.arange(100.0), name='MyDataSeries')
     p = pp.plot(s)
-    assert p._fig.dims['x'] == 'row'
-    assert p.canvas.xunit == 'dimensionless'
-    assert p.canvas.yunit == 'dimensionless'
+    assert p.canvas.dims['x'] == 'row'
+    assert p.canvas.units['x'] == 'dimensionless'
+    assert p.canvas.units['y'] == 'dimensionless'
     assert list(p._fig.artists.values())[0].label == 'MyDataSeries'
 
 
@@ -372,7 +372,7 @@ def test_plot_pandas_dataframe():
         }
     )
     p = pp.plot(df)
-    assert p._fig.dims['x'] == 'row'
-    assert p.canvas.xunit == 'dimensionless'
-    assert p.canvas.yunit == 'dimensionless'
+    assert p.canvas.dims['x'] == 'row'
+    assert p.canvas.units['x'] == 'dimensionless'
+    assert p.canvas.units['y'] == 'dimensionless'
     assert len(p._fig.artists) == 4