Repository: scipp/plopp

Issue:
Behaviour of autoscaling colorbar
Until now, the limits on the colorbar of a 2d plot are allowed to grow with time, but not shrink.
This means that if a figure receives new data, e.g. a new slice selected from moving a slider, if the range of new values is wider than the current range, the limits on the colorbar will autoscale to the new data.
If the range is smaller, the colorscale will not change.

This was intentional to give a better feel for the relative intensities of the different slices, while at the same time allowing the user to see new features as they appear.

However, this does have the strange effect that if a user is navigating through a 3d data set, sees a feature (some peaks), continues through the data, and then wants to come back to the peaks, the colorscale may now be different and the peaks could have been washed out if a higher range was displayed when the slider when past the peaks.

The two alternatives are:
1. Use the whole range of the 3d data as the limits, and keep them constant. This may mean that the user never sees those small peaks in the first place, but it has the advantage of moving the slider back to a previous location will always show the same image
2. Always autoscale on the displayed slice. This may be what the user expects the most, and would also show the same image when going back to a previous slider position. But the entire custom mechanism was designed to provide a better feel for the data.

A third option could be to keep the current mechanism, but then use the `Home` button to both autoscale the axes and the colorscale. Currently the `Home` button just goes back to the currently set limits, which may be wider than the displayed data, if the slider previously took you to a region where the range of values was wider.

@celinedurniak 

Discussion:

Patch:
diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py
index 55994e7..75a0989 100644
--- a/src/plopp/backends/matplotlib/canvas.py
+++ b/src/plopp/backends/matplotlib/canvas.py
@@ -46,6 +46,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.
+    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.
     aspect:
         The aspect ratio for the axes.
     cbar:
@@ -61,6 +65,7 @@ class Canvas:
         grid: bool = False,
         vmin: Union[sc.Variable, int, float] = None,
         vmax: Union[sc.Variable, int, float] = None,
+        autoscale: Literal['auto', 'grow'] = 'auto',
         aspect: Literal['auto', 'equal'] = 'auto',
         cbar: bool = False,
         **ignored,
@@ -82,6 +87,7 @@ class Canvas:
         self.xunit = None
         self.yunit = None
         self._own_axes = False
+        self._autoscale = autoscale
 
         if self.ax is None:
             self._own_axes = True
@@ -140,10 +146,11 @@ class Canvas:
             self.ax.autoscale()
             xmin, xmax = self.ax.get_xlim()
             ymin, ymax = self.ax.get_ylim()
-            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:
+            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()
@@ -159,10 +166,20 @@ class Canvas:
                         scale=self.yscale,
                     )
                 )
-                self._xmin = min(self._xmin, left.value)
-                self._xmax = max(self._xmax, right.value)
-                self._ymin = min(self._ymin, bottom.value)
-                self._ymax = max(self._ymax, top.value)
+                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
         if self._user_vmin is not None:
             self._ymin = maybe_variable_to_number(self._user_vmin, unit=self.yunit)
         if self._user_vmax is not None:
diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py
index f6fbf6c..cc6cb44 100644
--- a/src/plopp/backends/matplotlib/line.py
+++ b/src/plopp/backends/matplotlib/line.py
@@ -205,8 +205,8 @@ class Line:
         new_values = self._make_data()
 
         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_data(new_values['mask']['x'], new_values['mask']['y'])
             self._mask.set_visible(True)
         else:
             self._mask.set_visible(False)
diff --git a/src/plopp/core/limits.py b/src/plopp/core/limits.py
index a3a94cf..2da9a7a 100644
--- a/src/plopp/core/limits.py
+++ b/src/plopp/core/limits.py
@@ -28,7 +28,7 @@ def find_limits(
     return (scalar(finite_min, unit=x.unit), scalar(finite_max, unit=x.unit))
 
 
-def fix_empty_range(lims: Tuple[Variable, ...]) -> Tuple[Variable, ...]:
+def fix_empty_range(lims: Tuple[Variable, Variable]) -> Tuple[Variable, Variable]:
     """
     Range correction in case xmin == xmax
     """
diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py
index c6cbe38..65cf634 100644
--- a/src/plopp/core/utils.py
+++ b/src/plopp/core/utils.py
@@ -109,7 +109,7 @@ def maybe_variable_to_number(
     if hasattr(x, 'unit'):
         if unit is not None:
             x = x.to(unit=unit)
-        x = x.values
+        x = x.values if x.dims else x.value
     return x
 
 
diff --git a/src/plopp/functions/plot.py b/src/plopp/functions/plot.py
index 9abf0f1..1a0011b 100644
--- a/src/plopp/functions/plot.py
+++ b/src/plopp/functions/plot.py
@@ -29,6 +29,7 @@ def plot(
     title: Optional[str] = None,
     vmin: Optional[Union[Variable, int, float]] = None,
     vmax: Optional[Union[Variable, int, float]] = None,
+    autoscale: Literal['auto', 'grow'] = 'auto',
     **kwargs,
 ):
     """Plot a Scipp object.
@@ -73,6 +74,10 @@ def plot(
     vmax:
         Upper bound for data to be displayed (y-axis for 1d plots, colorscale for
         2d plots).
+    autoscale:
+        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.
     **kwargs:
         All other kwargs are directly forwarded to Matplotlib, the underlying plotting
         library. The underlying functions called are the following:
@@ -95,6 +100,7 @@ def plot(
         'title': title,
         'vmin': vmin,
         'vmax': vmax,
+        'autoscale': autoscale,
         'figsize': figsize,
         **kwargs,
     }
diff --git a/src/plopp/functions/slicer.py b/src/plopp/functions/slicer.py
index 8ee9d8d..8c4b49d 100644
--- a/src/plopp/functions/slicer.py
+++ b/src/plopp/functions/slicer.py
@@ -1,7 +1,9 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
-from typing import Dict, List, Union
+import warnings
+from functools import reduce
+from typing import Dict, List, Literal, Union
 
 import scipp as sc
 from numpy import ndarray
@@ -34,6 +36,16 @@ class Slicer:
         be a list of dims. If no dims are provided, the last dim will be kept in the
         case of a 2-dimensional input, while the last two dims will be kept in the case
         of higher dimensional inputs.
+    autoscale:
+        The behavior of the y-axis (1d plots) and color range (2d plots) 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.
+        If ``fixed``, the limits are fixed to the full data range and do not change
+        with time.
+    vmin:
+        The minimum value of the y-axis (1d plots) or color range (2d plots).
+    vmax:
+        The maximum value of the y-axis (1d plots) or color range (2d plots).
     crop:
         Set the axis limits. Limits should be given as a dict with one entry per
         dimension to be cropped.
@@ -46,6 +58,9 @@ class Slicer:
         obj: Union[VariableLike, ndarray, Dict[str, Union[VariableLike, ndarray]]],
         keep: List[str] = None,
         *,
+        autoscale: Literal['auto', 'grow', 'fixed'] = 'auto',
+        vmin: Union[VariableLike, int, float] = None,
+        vmax: Union[VariableLike, int, float] = None,
         crop: Dict[str, Dict[str, sc.Variable]] = None,
         **kwargs,
     ):
@@ -68,6 +83,19 @@ class Slicer:
                 f"were not found in the input's dimensions {ds.dims}."
             )
 
+        if autoscale == 'fixed':
+            if None not in (vmin, vmax):
+                warnings.warn(
+                    'Slicer plot: autoscale is set to "fixed", but vmin and vmax '
+                    'are also specified. They will override the autoscale setting.',
+                    RuntimeWarning,
+                )
+            if vmin is None:
+                vmin = reduce(min, [da.data.min() for da in ds.values()])
+            if vmax is None:
+                vmax = reduce(max, [da.data.max() for da in ds.values()])
+            autoscale = 'auto'  # Change back to something the figure understands
+
         from ..widgets import SliceWidget, slice_dims
 
         self.data_nodes = [Node(da) for da in ds.values()]
@@ -78,15 +106,32 @@ class Slicer:
             slice_dims(data_node, self.slider_node) for data_node in self.data_nodes
         ]
         if len(keep) == 1:
-            self.figure = figure1d(*self.slice_nodes, crop=crop, **kwargs)
+            self.figure = figure1d(
+                *self.slice_nodes,
+                crop=crop,
+                autoscale=autoscale,
+                vmin=vmin,
+                vmax=vmax,
+                **kwargs,
+            )
         elif len(keep) == 2:
-            self.figure = figure2d(*self.slice_nodes, crop=crop, **kwargs)
+            self.figure = figure2d(
+                *self.slice_nodes,
+                crop=crop,
+                autoscale=autoscale,
+                vmin=vmin,
+                vmax=vmax,
+                **kwargs,
+            )
 
 
 def slicer(
     obj: Union[VariableLike, ndarray, Dict[str, Union[VariableLike, ndarray]]],
     keep: List[str] = None,
     *,
+    autoscale: Literal['auto', 'grow', 'fixed'] = 'auto',
+    vmin: Union[VariableLike, int, float] = None,
+    vmax: Union[VariableLike, int, float] = None,
     crop: Dict[str, Dict[str, sc.Variable]] = None,
     **kwargs,
 ):
@@ -103,6 +148,16 @@ def slicer(
         be a list of dims. If no dims are provided, the last dim will be kept in the
         case of a 2-dimensional input, while the last two dims will be kept in the case
         of higher dimensional inputs.
+    autoscale:
+        The behavior of the y-axis (1d plots) and color range (2d plots) 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.
+        If ``fixed``, the limits are fixed to the full data range and do not change
+        with time.
+    vmin:
+        The minimum value of the y-axis (1d plots) or color range (2d plots).
+    vmax:
+        The maximum value of the y-axis (1d plots) or color range (2d plots).
     crop:
         Set the axis limits. Limits should be given as a dict with one entry per
         dimension to be cropped. Each entry should be a nested dict containing scalar
@@ -117,7 +172,15 @@ def slicer(
         A :class:`Box` which will contain a :class:`Figure` and slider widgets.
     """
     require_interactive_backend('slicer')
-    sl = Slicer(obj=obj, keep=keep, crop=crop, **kwargs)
+    sl = Slicer(
+        obj=obj,
+        keep=keep,
+        autoscale=autoscale,
+        vmin=vmin,
+        vmax=vmax,
+        crop=crop,
+        **kwargs,
+    )
     from ..widgets import Box
 
     return Box([sl.figure, sl.slider])
diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py
index 0e3118b..70c6036 100644
--- a/src/plopp/graphics/colormapper.py
+++ b/src/plopp/graphics/colormapper.py
@@ -1,7 +1,9 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
+from collections.abc import Iterable
 from copy import copy
+from functools import reduce
 from typing import Any, Literal, Optional, Tuple, Union
 
 import matplotlib as mpl
@@ -55,8 +57,6 @@ class ColorMapper:
     A class that handles conversion between data values and RGBA colors.
     It controls the normalization (linear or log), as well as the min and max limits
     for the color range.
-    If the min and max values are not set manually by the user, they are allowed to grow
-    with time but they do not shrink.
 
     Parameters
     ----------
@@ -75,6 +75,10 @@ class ColorMapper:
         The name of the colormap for masked data.
     norm:
         The colorscale normalization.
+    autoscale:
+        The behavior of the color range 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.
     vmin:
         The minimum value for the colorscale range. If a number (without a unit) is
         supplied, it is assumed that the unit is the same as the data unit.
@@ -94,6 +98,7 @@ class ColorMapper:
         cmap: str = 'viridis',
         mask_cmap: str = 'gray',
         norm: Literal['linear', 'log'] = 'linear',
+        autoscale: Literal['auto', 'grow'] = 'auto',
         vmin: Optional[Union[sc.Variable, int, float]] = None,
         vmax: Optional[Union[sc.Variable, int, float]] = None,
         nan_color: Optional[str] = None,
@@ -107,12 +112,14 @@ class ColorMapper:
         self.vmin = np.inf
         self.vmax = np.NINF
         self.norm = norm
+        self._autoscale = autoscale
 
         # Note that we need to set vmin/vmax for the LogNorm, if not an error is
         # raised when making the colorbar before any call to update is made.
         self.normalizer = _get_normalizer(self.norm)
         self.colorbar = None
         self.unit = None
+
         self.name = None
         self.changed = False
         self.artists = {}
@@ -128,10 +135,10 @@ class ColorMapper:
             self.colorbar = ColorbarBase(self.cax, cmap=self.cmap, norm=self.normalizer)
             self.cax.yaxis.set_label_coords(-0.9, 0.5)
 
-    def __setitem__(self, key, val):
-        self.artists[key] = val
+    def __setitem__(self, key: str, artist: Any):
+        self.artists[key] = artist
 
-    def __getitem__(self, key):
+    def __getitem__(self, key: str) -> Any:
         return self.artists[key]
 
     def to_widget(self):
@@ -166,39 +173,45 @@ class ColorMapper:
             colors[one_mask] = self.mask_cmap(self.normalizer(data.values[one_mask]))
         return colors
 
-    def autoscale(self, data: sc.DataArray):
+    def autoscale(self):
         """
-        Re-compute the min and max range of values, given new values.
-        The current range can grow but not shrink.
-
-        Parameters
-        ----------
-        data:
-            The data array containing the values to update the current range.
+        Re-compute the global min and max range of values by iterating over all the
+        artists. If autoscale is set to ``'auto'``, the limits adjust to he current
+        range. If it is set to ``'grow'``, limits can grow but not shrink.
         """
-        vmin, vmax = fix_empty_range(find_limits(data, scale=self.norm))
+        limits = [
+            fix_empty_range(find_limits(artist._data, scale=self.norm))
+            for artist in self.artists.values()
+        ]
+        vmin = reduce(min, [v[0] for v in limits])
+        vmax = reduce(max, [v[1] for v in limits])
         if self.user_vmin is not None:
-            self.vmin = maybe_variable_to_number(self.user_vmin, unit=self.unit)
-        elif vmin.value < self.vmin:
+            self.vmin = self.user_vmin
+        elif (vmin.value < self.vmin) or (self._autoscale == 'auto'):
             self.vmin = vmin.value
         if self.user_vmax is not None:
-            self.vmax = maybe_variable_to_number(self.user_vmax, unit=self.unit)
-        elif vmax.value > self.vmax:
+            self.vmax = self.user_vmax
+        elif (vmax.value > self.vmax) or (self._autoscale == 'auto'):
             self.vmax = vmax.value
 
-    def _set_artists_colors(self, key: str = None):
+        if self.vmin >= self.vmax:
+            if self.user_vmax is not None:
+                self.vmax = self.user_vmax
+                self.vmin = self.user_vmax - abs(self.user_vmax) * 0.1
+            else:
+                self.vmin = self.user_vmin
+                self.vmax = self.user_vmin + abs(self.user_vmin) * 0.1
+
+    def _set_artists_colors(self, keys: Iterable):
         """
         Update the colors of all the artists apart from the one that triggered the
         update, as those get updated by the figure.
 
         Parameters
         ----------
-        key:
-            Update all connected artists, apart from the one whose id is ``key``.
+        keys:
+            List of artists to update.
         """
-        keys = set(self.artists.keys())
-        if key is not None:
-            keys -= {key}
         for k in keys:
             self.artists[k].set_colors(self.rgba(self.artists[k].data))
 
@@ -215,11 +228,10 @@ class ColorMapper:
             self.normalizer.vmin = self.vmin
             self.normalizer.vmax = self.vmax
 
-    def update(self, data: sc.DataArray, key: str):
+    def update(self, key: str, data: sc.DataArray):
         """
         Update the colorscale bounds taking into account new values.
-        If the bounds have changed, we update all the colors in the artists that depend
-        on this ColorMapper. We also update the colorbar widget if it exists.
+        We also update the colorbar widget if it exists.
 
         Parameters
         ----------
@@ -228,16 +240,17 @@ class ColorMapper:
         key:
             The id of the node that provided this data.
         """
-        if self.unit is None:
-            self.unit = data.unit
-        elif data.unit != self.unit:
-            raise ValueError(
-                f'Incompatible unit: colormapper has unit {self.unit}, '
-                f'new data has unit {data.unit}.'
-            )
-
         if self.name is None:
             self.name = data.name
+            # If name is None, this is the first time update is called
+            if self.user_vmin is not None:
+                self.user_vmin = maybe_variable_to_number(
+                    self.user_vmin, unit=self.unit
+                )
+            if self.user_vmax is not None:
+                self.user_vmax = maybe_variable_to_number(
+                    self.user_vmax, unit=self.unit
+                )
         elif data.name != self.name:
             self.name = ''
         if self.cax is not None:
@@ -245,14 +258,16 @@ class ColorMapper:
             if self.unit is not None:
                 text += f'{" " if self.name else ""}[{self.unit}]'
             self.cax.set_ylabel(text)
-
         old_bounds = np.array([self.vmin, self.vmax])
-        self.autoscale(data=data)
+        self.autoscale()
         self._set_normalizer_limits()
 
         if not np.allclose(old_bounds, np.array([self.vmin, self.vmax])):
-            self._set_artists_colors(key=key)
             self._update_colorbar_widget()
+            keys = self.artists.keys()
+        else:
+            keys = [key]
+        self._set_artists_colors(keys)
 
     def toggle_norm(self):
         """
@@ -262,10 +277,9 @@ class ColorMapper:
         self.normalizer = _get_normalizer(self.norm)
         self.vmin = np.inf
         self.vmax = np.NINF
-        for artist in self.artists.values():
-            self.autoscale(data=artist._data)
+        self.autoscale()
         self._set_normalizer_limits()
-        self._set_artists_colors()
+        self._set_artists_colors(self.artists.keys())
         if self.colorbar is not None:
             self.colorbar.mappable.norm = self.normalizer
             self._update_colorbar_widget()
diff --git a/src/plopp/graphics/figimage.py b/src/plopp/graphics/figimage.py
index 75e8b45..b421bf1 100644
--- a/src/plopp/graphics/figimage.py
+++ b/src/plopp/graphics/figimage.py
@@ -36,6 +36,10 @@ class FigImage(BaseFig):
     vmax:
         Upper bound for the colorbar. If a number (without a unit) is supplied, it is
         assumed that the unit is the same as the data unit.
+    autoscale:
+        The behavior of the color range 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.
     scale:
         Control the scaling of the horizontal axis. For example, specify
         ``scale={'tof': 'log'}`` if you want log-scale for the ``tof`` dimension.
@@ -74,6 +78,7 @@ class FigImage(BaseFig):
         norm: Literal['linear', 'log'] = 'linear',
         vmin: Optional[Union[sc.Variable, int, float]] = None,
         vmax: Optional[Union[sc.Variable, int, float]] = None,
+        autoscale: Literal['auto', 'grow'] = 'auto',
         scale: Optional[Dict[str, str]] = None,
         aspect: Literal['auto', 'equal'] = 'auto',
         grid: bool = False,
@@ -99,6 +104,7 @@ class FigImage(BaseFig):
             norm=norm,
             vmin=vmin,
             vmax=vmax,
+            autoscale=autoscale,
             canvas=self.canvas,
         )
 
@@ -142,8 +148,6 @@ class FigImage(BaseFig):
                 ycoord, dim=self.dims['y'], unit=self.canvas.yunit
             )
 
-        self.colormapper.update(data=new_values, key=key)
-
         if key not in self.artists:
             image = backends.image(canvas=self.canvas, data=new_values, **self._kwargs)
             self.artists[key] = image
@@ -161,7 +165,7 @@ class FigImage(BaseFig):
                 self.canvas.yscale = self._scale[self.dims['y']]
 
         self.artists[key].update(new_values=new_values)
-        self.artists[key].set_colors(self.colormapper.rgba(self.artists[key].data))
+        self.colormapper.update(key=key, data=new_values)
 
     def crop(self, **limits):
         """
diff --git a/src/plopp/graphics/figline.py b/src/plopp/graphics/figline.py
index 48b6913..a81fa6a 100644
--- a/src/plopp/graphics/figline.py
+++ b/src/plopp/graphics/figline.py
@@ -28,6 +28,10 @@ class FigLine(BaseFig):
     vmax:
         Upper bound 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.
+    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.
     scale:
         Control the scaling of the horizontal axis. For example, specify
         ``scale={'tof': 'log'}`` if you want log-scale for the ``tof`` dimension.
@@ -66,6 +70,7 @@ class FigLine(BaseFig):
         norm: Literal['linear', 'log'] = 'linear',
         vmin: Optional[Union[sc.Variable, int, float]] = None,
         vmax: Optional[Union[sc.Variable, int, float]] = None,
+        autoscale: Literal['auto', 'grow'] = 'auto',
         scale: Optional[Dict[str, str]] = None,
         errorbars: bool = True,
         mask_color: str = 'black',
@@ -92,6 +97,7 @@ class FigLine(BaseFig):
             title=title,
             vmin=vmin,
             vmax=vmax,
+            autoscale=autoscale,
             **kwargs
         )
         self.canvas.yscale = norm
diff --git a/src/plopp/graphics/figscatter3d.py b/src/plopp/graphics/figscatter3d.py
index a7eba41..c74c1c5 100644
--- a/src/plopp/graphics/figscatter3d.py
+++ b/src/plopp/graphics/figscatter3d.py
@@ -126,8 +126,6 @@ class FigScatter3d(BaseFig):
                 unit=self.canvas.zunit, copy=False
             )
 
-        self.colormapper.update(data=new_values, key=key)
-
         if key not in self.artists:
             pts = backends.point_cloud(
                 data=new_values, x=self._x, y=self._y, z=self._z, **self._kwargs
@@ -139,7 +137,7 @@ class FigScatter3d(BaseFig):
                 self.canvas.make_outline(limits=self.get_limits())
 
         self.artists[key].update(new_values=new_values)
-        self.artists[key].set_colors(self.colormapper.rgba(self.artists[key].data))
+        self.colormapper.update(key=key, data=new_values)
 
     def get_limits(self) -> Tuple[sc.Variable, sc.Variable, sc.Variable]:
         """


Test patch:
diff --git a/tests/backends/matplotlib/mpl_figimage_test.py b/tests/backends/matplotlib/mpl_figimage_test.py
index e2983e7..5d18ba3 100644
--- a/tests/backends/matplotlib/mpl_figimage_test.py
+++ b/tests/backends/matplotlib/mpl_figimage_test.py
@@ -24,7 +24,7 @@ def test_update_on_one_mesh_changes_colors_on_second_mesh():
     b = Node(da2)
     f = FigImage(a, b)
     old_b_colors = f.artists[b.id]._mesh.get_facecolors()
-    a.func = lambda: da1 * 1.1
+    a.func = lambda: da1 * 2.1
     a.notify_children('updated a')
     # No change because the update did not change the colorbar limits
     assert np.allclose(old_b_colors, f.artists[b.id]._mesh.get_facecolors())
diff --git a/tests/functions/slicer_test.py b/tests/functions/slicer_test.py
index 9ff4fdb..a3060c9 100644
--- a/tests/functions/slicer_test.py
+++ b/tests/functions/slicer_test.py
@@ -97,3 +97,15 @@ def test_raises_when_requested_keep_dims_do_not_exist():
         ValueError, match='Slicer plot: one or more of the requested dims to be kept'
     ):
         Slicer(da, keep=['time'])
+
+
+def test_autoscale_fixed():
+    da = sc.DataArray(
+        data=sc.arange('x', 5 * 10 * 20).fold(dim='x', sizes={'z': 20, 'y': 10, 'x': 5})
+    )
+    sl = Slicer(da, keep=['y', 'x'], autoscale='fixed')
+    assert sl.figure._fig.colormapper.vmin == 0
+    assert sl.figure._fig.colormapper.vmax == 5 * 10 * 20 - 1
+    sl.slider.controls['z']['slider'].value = 5
+    assert sl.figure._fig.colormapper.vmin == 0
+    assert sl.figure._fig.colormapper.vmax == 5 * 10 * 20 - 1
diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py
index 7045ac3..6275267 100644
--- a/tests/graphics/colormapper_test.py
+++ b/tests/graphics/colormapper_test.py
@@ -19,6 +19,9 @@ class DummyChild:
     def set_colors(self, colors):
         self._colors = colors
 
+    def update(self, data):
+        self._data = data
+
     @property
     def data(self):
         return self._data
@@ -47,27 +50,50 @@ def test_norm():
 def test_autoscale():
     da = data_array(ndim=2, unit='K')
     mapper = ColorMapper()
+    artist = DummyChild(da)
+    mapper['data'] = artist
 
-    mapper.autoscale(data=da)
+    mapper.update(key='data', data=da)
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
 
     const = 2.3
-    mapper.autoscale(data=da * const)
+    artist.update(da * const)
+    mapper.update(key='data', data=da * const)
     assert mapper.vmin == (da.min() * const).value
     assert mapper.vmax == (da.max() * const).value
 
 
-def test_rescale_limits_do_not_shrink():
+def test_auto_rescale_limits_can_shrink():
     da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper()
+    mapper = ColorMapper(autoscale='auto')
+    artist = DummyChild(da)
+    mapper['data'] = artist
+
+    mapper.update(key='data', data=da)
+    assert mapper.vmin == da.min().value
+    assert mapper.vmax == da.max().value
+
+    const = 0.5
+    artist.update(da * const)
+    mapper.update(key='data', data=da * const)
+    assert mapper.vmin == da.min().value * const
+    assert mapper.vmax == da.max().value * const
+
+
+def test_grow_rescale_limits_do_not_shrink():
+    da = data_array(ndim=2, unit='K')
+    mapper = ColorMapper(autoscale='grow')
+    artist = DummyChild(da)
+    mapper['data'] = artist
 
-    mapper.autoscale(data=da)
+    mapper.update(key='data', data=da)
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
 
     const = 0.5
-    mapper.autoscale(data=da * const)
+    artist.update(da * const)
+    mapper.update(key='data', data=da * const)
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
 
@@ -75,7 +101,9 @@ def test_rescale_limits_do_not_shrink():
 def test_correct_normalizer_limits():
     da = sc.DataArray(data=sc.array(dims=['y', 'x'], values=[[1, 2], [3, 4]]))
     mapper = ColorMapper()
-    mapper.update(data=da, key=None)
+    artist = DummyChild(da)
+    mapper['data'] = artist
+    mapper.update(key='data', data=da)
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
     # The normalizer initially has limits [0, 1].
@@ -87,23 +115,27 @@ def test_correct_normalizer_limits():
 
 
 def test_vmin_vmax():
-    da = data_array(ndim=2, unit='K')
+    da = data_array(ndim=2, unit='K') * 100.0
     vmin = sc.scalar(-0.1, unit='K')
     vmax = sc.scalar(3.5, unit='K')
     mapper = ColorMapper(vmin=vmin, vmax=vmax)
-    mapper.update(data=da * 100.0, key=None)
-    assert sc.identical(mapper.user_vmin, vmin)
-    assert sc.identical(mapper.user_vmax, vmax)
+    artist = DummyChild(da)
+    mapper['data'] = artist
+    mapper.update(key='data', data=da)
+    assert mapper.user_vmin == vmin.value
+    assert mapper.user_vmax == vmax.value
     assert mapper.vmin == vmin.value
     assert mapper.vmax == vmax.value
 
 
 def test_vmin_vmax_no_variable():
-    da = data_array(ndim=2, unit='K')
+    da = data_array(ndim=2, unit='K') * 100.0
     vmin = -0.1
     vmax = 3.5
     mapper = ColorMapper(vmin=vmin, vmax=vmax)
-    mapper.update(data=da * 100.0, key=None)
+    artist = DummyChild(da)
+    mapper['data'] = artist
+    mapper.update(key='data', data=da)
     assert mapper.user_vmin == vmin
     assert mapper.user_vmax == vmax
     assert mapper.vmin == vmin
@@ -114,7 +146,7 @@ def test_toggle_norm():
     mapper = ColorMapper()
     da = data_array(ndim=2, unit='K')
     mapper['child1'] = DummyChild(da)
-    mapper.autoscale(da)
+    mapper.update(key='child1', data=da)
     assert mapper.norm == 'linear'
     assert isinstance(mapper.normalizer, Normalize)
     assert mapper.vmin == da.min().value
@@ -130,12 +162,15 @@ def test_toggle_norm():
 def test_update_changes_limits():
     da = data_array(ndim=2, unit='K')
     mapper = ColorMapper()
+    artist = DummyChild(da)
+    mapper['data'] = artist
 
     mapper.update(data=da, key=None)
     assert mapper.normalizer.vmin == da.min().value
     assert mapper.normalizer.vmax == da.max().value
 
     const = 2.3
+    artist.update(da * const)
     mapper.update(data=da * const, key=None)
     assert mapper.normalizer.vmin == (da.min() * const).value
     assert mapper.normalizer.vmax == (da.max() * const).value
@@ -158,18 +193,54 @@ def test_rgba_with_masks():
 def test_colorbar_updated_on_rescale():
     da = data_array(ndim=2, unit='K')
     mapper = ColorMapper()
+    artist = DummyChild(da)
+    key = 'data'
+    mapper[key] = artist
 
-    mapper.update(data=da, key=None)
+    mapper.update(data=da, key=key)
     _ = mapper.to_widget()
     old_image = mapper.widget.value
     old_image_array = old_image
 
     # Update with the same values should not make a new colorbar image
-    mapper.update(data=da, key=None)
+    artist.update(da)
+    mapper.update(data=da, key=key)
     assert old_image is mapper.widget.value
 
+    # Update with new values should make a new colorbar image
     const = 2.3
-    mapper.update(data=da * const, key=None)
+    artist.update(da * const)
+    mapper.update(data=da * const, key=key)
+    assert old_image_array != mapper.widget.value
+
+
+def test_colorbar_does_not_update_on_rescale_if_limits_can_only_grow():
+    da = data_array(ndim=2, unit='K')
+    mapper = ColorMapper(autoscale='grow')
+    artist = DummyChild(da)
+    key = 'data'
+    mapper[key] = artist
+
+    mapper.update(data=da, key=key)
+    _ = mapper.to_widget()
+    old_image = mapper.widget.value
+    old_image_array = old_image
+
+    # Update with the same values should not make a new colorbar image
+    artist.update(da)
+    mapper.update(data=da, key=key)
+    assert old_image is mapper.widget.value
+
+    # Update with a smaller range should not make a new colorbar image
+    const = 0.8
+    artist.update(da * const)
+    mapper.update(data=da * const, key=key)
+    assert old_image is mapper.widget.value
+
+    # Update with new values should make a new colorbar image
+    const = 2.3
+    artist.update(da * const)
+    mapper.update(data=da * const, key=key)
     assert old_image_array != mapper.widget.value
 
 
@@ -186,3 +257,33 @@ def test_colorbar_cbar_false_overrides_cax():
 
     mapper = ColorMapper(cbar=False, canvas=Canvas(cax=0))
     assert mapper.colorbar is None
+
+
+def test_autoscale_auto_vmin_set():
+    da = data_array(ndim=2, unit='K')
+    mapper = ColorMapper(autoscale='auto', vmin=-0.5)
+    artist = DummyChild(da)
+    key = 'data'
+    mapper[key] = artist
+    mapper.update(data=da, key=key)
+    assert mapper.vmin == -0.5
+    assert mapper.vmax == da.max().value
+    # Make sure it handles when da.max() is greater than vmin
+    mapper.update(data=da - sc.scalar(5.0, unit='K'), key=key)
+    assert mapper.vmin == -0.5
+    assert mapper.vmin < mapper.vmax
+
+
+def test_autoscale_auto_vmax_set():
+    da = data_array(ndim=2, unit='K')
+    mapper = ColorMapper(autoscale='auto', vmax=0.5)
+    artist = DummyChild(da)
+    key = 'data'
+    mapper[key] = artist
+    mapper.update(data=da, key=key)
+    assert mapper.vmax == 0.5
+    assert mapper.vmin == da.min().value
+    # Make sure it handles when da.min() is greater than vmax
+    mapper.update(data=da + sc.scalar(5.0, unit='K'), key=key)
+    assert mapper.vmax == 0.5
+    assert mapper.vmin < mapper.vmax
diff --git a/tests/graphics/figline_test.py b/tests/graphics/figline_test.py
index e3f61cc..4c343a4 100644
--- a/tests/graphics/figline_test.py
+++ b/tests/graphics/figline_test.py
@@ -102,9 +102,21 @@ def test_update_grows_limits():
     assert new_lims[1] > old_lims[1]
 
 
-def test_update_does_not_shrink_limits():
+def test_update_does_shrink_limits_if_auto_mode():
     da = data_array(ndim=1)
-    fig = FigLine(Node(da))
+    fig = FigLine(Node(da), autoscale='auto')
+    old_lims = fig.canvas.yrange
+    key = list(fig.artists.keys())[0]
+    const = 0.5
+    fig.update(da * const, key=key)
+    new_lims = fig.canvas.yrange
+    assert new_lims[0] == old_lims[0] * const
+    assert new_lims[1] == old_lims[1] * const
+
+
+def test_update_does_not_shrink_limits_if_grow_mode():
+    da = data_array(ndim=1)
+    fig = FigLine(Node(da), autoscale='grow')
     old_lims = fig.canvas.yrange
     key = list(fig.artists.keys())[0]
     fig.update(da * 0.5, key=key)