Repository: scipp/plopp

Issue:
Adding operators to plots?
Some projects like Holoviz have added operators to plots (see e.g. [here](https://holoviz.org/tutorial/Composing_Plots.html)).
For example, they use the `*` operators between two plots to overplot the data from both onto the same axes.
They use the `+` operator to make tiled plots.

Do we want to add something similar?
While I am not sure about the usefulness of the `*` operator (and also not sure whether it is a good choice), maybe some aspects are interesting? Making tiled plots with `p1 + p2` is definitely appealing, instead of having to always use the `Box([p1, p2])`.
I don't know how you would make horizontal and vertical tiles. Maybe the `/` operator could be used to make one plot above the other.
So something with 4 panels would be `(p1 + p2) / (p3 + p4)`.

I think having operators would definitely be beneficial for cropping (setting the axes limits).
Right now, to set the axis limits, one needs to use a rather strange syntax to set the limits for the `time` dimension:
`da.plot(crop={'time': {'min': 2.5, 'max': 40.}})`
It would be nice if instead we could use the same slicing syntax as we have on sc.DataArray to crop:
`da.plot()['time', 2.5:40.]`.

Note however that using integers may become confusing, as `da.plot()['time', 2:40]` would be cropping on the values of the `time` coordinate, not slicing using integer indices.

Discussion:

Patch:
diff --git a/docs/about/reference.rst b/docs/about/reference.rst
index 741985f..57bac43 100644
--- a/docs/about/reference.rst
+++ b/docs/about/reference.rst
@@ -43,6 +43,7 @@ Graphics
    graphics.figure1d
    graphics.figure2d
    graphics.figure3d
+   graphics.tiled
 
 Widgets and tools
 =================
diff --git a/docs/about/reference/matplotlib.rst b/docs/about/reference/matplotlib.rst
index c49419c..cb7b909 100644
--- a/docs/about/reference/matplotlib.rst
+++ b/docs/about/reference/matplotlib.rst
@@ -13,3 +13,4 @@ Matplotlib backend
    backends.matplotlib.image.Image
    backends.matplotlib.static.StaticFig
    backends.matplotlib.interactive.InteractiveFig
+   backends.matplotlib.tiled.Tiled
diff --git a/docs/customization/subplots.ipynb b/docs/customization/subplots.ipynb
index 5aaedd5..b84fa83 100644
--- a/docs/customization/subplots.ipynb
+++ b/docs/customization/subplots.ipynb
@@ -2,19 +2,98 @@
  "cells": [
   {
    "cell_type": "markdown",
-   "id": "32fb725d-602c-433b-8522-bd8be63fa4d0",
+   "id": "5e8fe930-4b8a-4a27-9f38-493b1cd10d78",
    "metadata": {},
    "source": [
     "# Subplots / Tiled plots\n",
     "\n",
-    "There are two ways to create subplot layouts with Plopp:\n",
+    "There are three ways to create subplot layouts with Plopp:\n",
     "\n",
+    "- Using the in-built `tiled` functionality\n",
     "- Using manually created Matplotlib axes\n",
     "- Placing figures inside container widgets\n",
     "\n",
-    "The first method will work with any (static or interactive) backend,\n",
-    "while the second will only work with the interactive (`%matplotlib widget`) backend.\n",
+    "The first two methods will work with any (static or interactive) backend,\n",
+    "while the third will only work with the interactive (`%matplotlib widget`) backend.\n",
     "\n",
+    "## Using `tiled`:\n",
+    "\n",
+    "### Leveraging figure operator overloading\n",
+    "\n",
+    "The fastest way to make simple subplots is to use the `+` and `/` operators on the figures you create.\n",
+    "\n",
+    "Adding two figures will place them side-by-side,\n",
+    "while dividing a figure by another will stack them vertically."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "36663ed6-ab9e-48bb-9901-d48fe8c8983f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib inline\n",
+    "import plopp as pp\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "p1 = pp.data.data1d().plot()\n",
+    "p2 = pp.data.data2d().plot()\n",
+    "\n",
+    "p1 + p2"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1350aaf8-0f69-4a7c-9198-28757ff2edbe",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p1 / p2"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "96bce5cc-5267-4fd8-9ab7-8dae6ab71ed3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(p1 + p2) / (p2 + p1)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "738feddd-e9d7-4c32-a93b-8544fbd54a6e",
+   "metadata": {},
+   "source": [
+    "### More complex configurations\n",
+    "\n",
+    "While the figure operators are using `tiled` under-the-hood,\n",
+    "it is also possible to use `tiled` directly.\n",
+    "\n",
+    "Its use is based on Matplotlib's [GridSpec](https://matplotlib.org/stable/gallery/subplots_axes_and_figures/gridspec_multicolumn.html#sphx-glr-gallery-subplots-axes-and-figures-gridspec-multicolumn-py)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5ab290fa-369b-40ea-8c9c-b13b0e77d79e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tiled = pp.tiled(nrows=1, ncols=3)\n",
+    "tiled[0, :2] = p1\n",
+    "tiled[0, 2] = p2\n",
+    "tiled"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f30a58ea-bc4d-41ce-9211-4cd2fab9c0e3",
+   "metadata": {},
+   "source": [
     "## Using Matplotlib axes"
    ]
   },
@@ -186,8 +265,7 @@
    "mimetype": "text/x-python",
    "name": "python",
    "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.9.15"
+   "pygments_lexer": "ipython3"
   }
  },
  "nbformat": 4,
diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py
index b985682..3be01ac 100644
--- a/src/plopp/__init__.py
+++ b/src/plopp/__init__.py
@@ -17,7 +17,7 @@ backends = BackendManager()
 from . import data
 from .core import Node, View, input_node, node, show_graph, widget_node
 from .functions import inspector, plot, scatter3d, slicer, superplot
-from .graphics import Camera, figure1d, figure2d, figure3d
+from .graphics import Camera, figure1d, figure2d, figure3d, tiled
 
 
 def patch_scipp():
diff --git a/src/plopp/backends/manager.py b/src/plopp/backends/manager.py
index 1495b4a..a063a47 100644
--- a/src/plopp/backends/manager.py
+++ b/src/plopp/backends/manager.py
@@ -109,3 +109,10 @@ class BackendManager:
         except AttributeError:
             raise ValueError(f'Unsupported backend \'{self["3d"]}\' for figure3d.')
         return _figure3d(*args, **kwargs)
+
+    def tiled(self, *args, **kwargs):
+        try:
+            _tiled = self._backends['2d'].tiled
+        except AttributeError:
+            raise ValueError(f'Unsupported backend \'{self["2d"]}\' for tiled.')
+        return _tiled(*args, **kwargs)
diff --git a/src/plopp/backends/matplotlib/__init__.py b/src/plopp/backends/matplotlib/__init__.py
index 3694bef..34ea6f3 100644
--- a/src/plopp/backends/matplotlib/__init__.py
+++ b/src/plopp/backends/matplotlib/__init__.py
@@ -61,3 +61,9 @@ class MatplotlibBackend:
         details.
         """
         return _make_figure(*args, **kwargs)
+
+    def tiled(self, *args, **kwargs):
+        """ """
+        from .tiled import Tiled as TiledMpl
+
+        return TiledMpl(*args, **kwargs)
diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py
index ff01ee6..2fcb8c2 100644
--- a/src/plopp/backends/matplotlib/canvas.py
+++ b/src/plopp/backends/matplotlib/canvas.py
@@ -101,7 +101,7 @@ class Canvas:
         else:
             self.fig = self.ax.get_figure()
 
-        if cbar and self.cax is None:
+        if cbar and (self.cax is None):
             divider = make_axes_locatable(self.ax)
             self.cax = divider.append_axes("right", "4%", pad="5%")
 
@@ -182,9 +182,13 @@ 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.units['y'])
+            self._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._user_vmax, unit=self.units['y'])
+            self._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)
@@ -429,6 +433,17 @@ class Canvas:
     def yrange(self, value: Tuple[float, float]):
         self.ax.set_ylim(value)
 
+    @property
+    def grid(self) -> str:
+        """
+        Get or set the visibility of the grid.
+        """
+        return self.ax.axes.get_xgridlines()[0].get_visible()
+
+    @grid.setter
+    def grid(self, visible: bool):
+        self.ax.grid(visible)
+
     def reset_mode(self):
         """
         Reset the Matplotlib toolbar mode to nothing, to disable all Zoom/Pan tools.
diff --git a/src/plopp/backends/matplotlib/figure.py b/src/plopp/backends/matplotlib/figure.py
new file mode 100644
index 0000000..6ed8e9c
--- /dev/null
+++ b/src/plopp/backends/matplotlib/figure.py
@@ -0,0 +1,92 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+
+class Figure:
+    """
+    Mixin class for Matplotlib figures
+    """
+
+    def __init_figure__(self, FigConstructor, *args, **kwargs):
+        self._fig = FigConstructor(*args, **kwargs)
+        self._args = args
+        self._kwargs = kwargs
+        self._fig_constructor = FigConstructor
+
+    @property
+    def fig(self):
+        """
+        Get the underlying Matplotlib figure.
+        """
+        return self._fig.canvas.fig
+
+    @property
+    def ax(self):
+        """
+        Get the underlying Matplotlib axes.
+        """
+        return self._fig.canvas.ax
+
+    @property
+    def cax(self):
+        """
+        Get the underlying Matplotlib colorbar axes.
+        """
+        return self._fig.canvas.cax
+
+    @property
+    def canvas(self):
+        return self._fig.canvas
+
+    @property
+    def artists(self):
+        return self._fig.artists
+
+    @property
+    def graph_nodes(self):
+        return self._fig.graph_nodes
+
+    @property
+    def id(self):
+        return self._fig.id
+
+    def crop(self, **limits):
+        """
+        Set the axes limits according to the crop parameters.
+
+        Parameters
+        ----------
+        **limits:
+            Min and max limits for each dimension to be cropped.
+        """
+        return self._fig.crop(**limits)
+
+    def save(self, filename, **kwargs):
+        """
+        Save the figure to file.
+        The default directory for writing the file is the same as the
+        directory where the script or notebook is running.
+
+        Parameters
+        ----------
+        filename:
+            Name of the output file. Possible file extensions are ``.jpg``, ``.png``,
+            ``.svg``, and ``.pdf``.
+        """
+        return self._fig.canvas.save(filename, **kwargs)
+
+    def update(self, *args, **kwargs):
+        return self._fig.update(*args, **kwargs)
+
+    def notify_view(self, *args, **kwargs):
+        return self._fig.notify_view(*args, **kwargs)
+
+    def __add__(self, other):
+        from .tiled import hstack
+
+        return hstack(self, other)
+
+    def __truediv__(self, other):
+        from .tiled import vstack
+
+        return vstack(self, other)
diff --git a/src/plopp/backends/matplotlib/image.py b/src/plopp/backends/matplotlib/image.py
index 93f431c..5d6b392 100644
--- a/src/plopp/backends/matplotlib/image.py
+++ b/src/plopp/backends/matplotlib/image.py
@@ -202,6 +202,7 @@ class Image:
             New data to update the mesh values from.
         """
         self._data = new_values
+        self._data_with_bin_edges.data = new_values.data
 
     def format_coord(self, x: float, y: float) -> str:
         """
diff --git a/src/plopp/backends/matplotlib/interactive.py b/src/plopp/backends/matplotlib/interactive.py
index 85e6b8b..d6b1dc0 100644
--- a/src/plopp/backends/matplotlib/interactive.py
+++ b/src/plopp/backends/matplotlib/interactive.py
@@ -4,15 +4,16 @@
 from ipywidgets import HBox, VBox
 
 from ...widgets import HBar, VBar, make_toolbar_canvas2d
+from .figure import Figure
 
 
-class InteractiveFig(VBox):
+class InteractiveFig(Figure, VBox):
     """
-    Create an interactive figure.
+    Create an interactive Matplotlib figure.
     """
 
-    def __init__(self, *args, FigConstructor, **kwargs):
-        self._fig = FigConstructor(*args, **kwargs)
+    def __init__(self, FigConstructor, *args, **kwargs):
+        self.__init_figure__(FigConstructor, *args, **kwargs)
         self.toolbar = make_toolbar_canvas2d(
             canvas=self._fig.canvas, colormapper=getattr(self._fig, 'colormapper', None)
         )
@@ -28,71 +29,3 @@ class InteractiveFig(VBox):
                 self.bottom_bar,
             ]
         )
-
-    @property
-    def fig(self):
-        """
-        Get the underlying Matplotlib figure.
-        """
-        return self._fig.canvas.fig
-
-    @property
-    def ax(self):
-        """
-        Get the underlying Matplotlib axes.
-        """
-        return self._fig.canvas.ax
-
-    @property
-    def cax(self):
-        """
-        Get the underlying Matplotlib colorbar axes.
-        """
-        return self._fig.canvas.cax
-
-    @property
-    def canvas(self):
-        return self._fig.canvas
-
-    @property
-    def artists(self):
-        return self._fig.artists
-
-    @property
-    def graph_nodes(self):
-        return self._fig.graph_nodes
-
-    @property
-    def id(self):
-        return self._fig.id
-
-    def crop(self, **limits):
-        """
-        Set the axes limits according to the crop parameters.
-
-        Parameters
-        ----------
-        **limits:
-            Min and max limits for each dimension to be cropped.
-        """
-        return self._fig.crop(**limits)
-
-    def save(self, filename, **kwargs):
-        """
-        Save the figure to file.
-        The default directory for writing the file is the same as the
-        directory where the script or notebook is running.
-
-        Parameters
-        ----------
-        filename:
-            Name of the output file. Possible file extensions are ``.jpg``, ``.png``,
-            ``.svg``, and ``.pdf``.
-        """
-        return self._fig.canvas.save(filename, **kwargs)
-
-    def update(self, *args, **kwargs):
-        return self._fig.update(*args, **kwargs)
-
-    def notify_view(self, *args, **kwargs):
-        return self._fig.notify_view(*args, **kwargs)
diff --git a/src/plopp/backends/matplotlib/static.py b/src/plopp/backends/matplotlib/static.py
index 8db96b9..60338f1 100644
--- a/src/plopp/backends/matplotlib/static.py
+++ b/src/plopp/backends/matplotlib/static.py
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
+from .figure import Figure
 from .utils import fig_to_bytes
 
 
@@ -15,26 +16,37 @@ def _make_svg_repr(fig):
 REPR_MAP = {'png': _make_png_repr, 'svg': _make_svg_repr}
 
 
-class StaticFig:
+def get_repr_maker(form=None, npoints=0):
     """
-    Create a static figure to represent one-dimensional data.
+    Get the function used to create the mimebundle representation of a figure,
+    based on the format requested and the number of points in the figure.
     """
+    if form is None:
+        form = 'png' if (npoints > 20_000) else 'svg'
+    return REPR_MAP[form.lower()]
 
-    def __init__(self, *args, FigConstructor, **kwargs):
-        self._fig = FigConstructor(*args, **kwargs)
+
+class StaticFig(Figure):
+    """
+    Create a static Matplotlib figure.
+    The output will be either svg or png, depending on the number of drawn onto the
+    canvas.
+    """
+
+    def __init__(self, FigConstructor, *args, **kwargs):
+        self.__init_figure__(FigConstructor, *args, **kwargs)
 
     def _repr_mimebundle_(self, include=None, exclude=None) -> dict:
         """
         Mimebundle display representation for jupyter notebooks.
         """
-        out = {'text/plain': 'Figure'}
+        str_repr = str(self.fig)
+        out = {'text/plain': str_repr[:-1] + f', {len(self.artists)} artists)'}
         if self._fig._repr_format is not None:
-            repr_maker = REPR_MAP[self._fig._repr_format.lower()]
+            repr_maker = get_repr_maker(form=self._fig._repr_format)
         else:
-            npoints = 0
-            for line in self._fig.canvas.ax.lines:
-                npoints += len(line.get_xdata())
-            repr_maker = REPR_MAP['png' if (npoints > 20_000) else 'svg']
+            npoints = sum(len(line.get_xdata()) for line in self._fig.canvas.ax.lines)
+            repr_maker = get_repr_maker(npoints=npoints)
         out.update(repr_maker(self._fig.canvas.fig))
         return out
 
@@ -43,75 +55,3 @@ class StaticFig:
         Convert the Matplotlib figure to an image widget.
         """
         return self._fig.canvas.to_image()
-
-    @property
-    def fig(self):
-        """
-        Get the underlying Matplotlib figure.
-        """
-        return self._fig.canvas.fig
-
-    @property
-    def ax(self):
-        """
-        Get the underlying Matplotlib axes.
-        """
-        return self._fig.canvas.ax
-
-    @property
-    def cax(self):
-        """
-        Get the underlying Matplotlib colorbar axes.
-        """
-        return self._fig.canvas.cax
-
-    @property
-    def canvas(self):
-        return self._fig.canvas
-
-    @property
-    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
-
-    @property
-    def id(self):
-        return self._fig.id
-
-    def crop(self, **limits):
-        """
-        Set the axes limits according to the crop parameters.
-
-        Parameters
-        ----------
-        **limits:
-            Min and max limits for each dimension to be cropped.
-        """
-        return self._fig.crop(**limits)
-
-    def save(self, filename, **kwargs):
-        """
-        Save the figure to file.
-        The default directory for writing the file is the same as the
-        directory where the script or notebook is running.
-
-        Parameters
-        ----------
-        filename:
-            Name of the output file. Possible file extensions are ``.jpg``, ``.png``,
-            ``.svg``, and ``.pdf``.
-        """
-        return self._fig.canvas.save(filename, **kwargs)
-
-    def update(self, *args, **kwargs):
-        return self._fig.update(*args, **kwargs)
-
-    def notify_view(self, *args, **kwargs):
-        return self._fig.notify_view(*args, **kwargs)
diff --git a/src/plopp/backends/matplotlib/tiled.py b/src/plopp/backends/matplotlib/tiled.py
new file mode 100644
index 0000000..deaddbb
--- /dev/null
+++ b/src/plopp/backends/matplotlib/tiled.py
@@ -0,0 +1,206 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+from __future__ import annotations
+
+from typing import Optional, Tuple, Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib import gridspec
+
+from ..protocols import FigureLike
+from .static import get_repr_maker
+from .utils import copy_figure, is_interactive_backend, silent_mpl_figure
+
+
+class Tiled:
+    """
+    A tiled figure.
+    This is based on Matpotlib's GridSpec.
+
+    Parameters
+    ----------
+    nrows:
+        Number of rows.
+    ncols:
+        Number of columns.
+    figsize:
+        Figure size (width, height) in inches.
+    **kwargs:
+        Additional arguments passed to :class:`matplotlib.gridspec.GridSpec`.
+
+    Examples
+    --------
+    Create a tiled figure with two plots stacked vertically:
+
+      >>> tiled = pp.tiled(2, 1)
+      >>> tiled[0] = da1.plot()
+      >>> tiled[1] = da2.plot()
+
+    Create a tiled 2x2 figure:
+
+      >>> tiled = pp.tiled(2, 2)
+      >>> tiled[0, 0] = da1.plot()
+      >>> tiled[0, 1] = da2.plot()
+      >>> tiled[1, 0] = da3.plot()
+      >>> tiled[1, 1] = da4.plot()
+
+    Create a tiled figure with two figures side by side and the first is twice as wide:
+
+      >>> tiled = pp.tiled(1, 3)
+      >>> tiled[0, :2] = da1.plot()
+      >>> tiled[0, 2] = da2.plot()
+
+    """
+
+    def __init__(
+        self,
+        nrows: int,
+        ncols: int,
+        figsize: Optional[Tuple[float, float]] = None,
+        **kwargs,
+    ):
+        self.nrows = nrows
+        self.ncols = ncols
+        with silent_mpl_figure():
+            self.fig = plt.figure(
+                figsize=(min(6.0 * ncols, 15.0), min(4.0 * nrows, 15.0))
+                if figsize is None
+                else figsize,
+            )
+        self.gs = gridspec.GridSpec(nrows, ncols, figure=self.fig, **kwargs)
+        self.axes = []
+        self.views = np.full((nrows, ncols), None)
+        self._history = []
+
+    def __setitem__(
+        self,
+        inds: Union[int, slice, Tuple[int, int], Tuple[slice, slice]],
+        view: FigureLike,
+    ):
+        new_view = copy_figure(view, ax=self.fig.add_subplot(self.gs[inds]))
+        self.fig.tight_layout()
+        self.views[inds] = new_view
+        self._history.append((inds, new_view))
+
+    def __getitem__(
+        self, inds: Union[int, slice, Tuple[int, int], Tuple[slice, slice]]
+    ) -> FigureLike:
+        return self.views[inds]
+
+    def _repr_mimebundle_(self, include=None, exclude=None) -> dict:
+        """
+        Mimebundle display representation for jupyter notebooks.
+        """
+        if is_interactive_backend():
+            return self.fig.canvas._repr_mimebundle_(include=include, exclude=exclude)
+        else:
+            out = {'text/plain': f'TiledFigure(nrows={self.nrows}, ncols={self.ncols})'}
+            npoints = sum(
+                len(line.get_xdata()) for ax in self.axes for line in ax.lines
+            )
+            out.update(get_repr_maker(npoints=npoints)(self.fig))
+            return out
+
+    def save(self, filename: str, **kwargs):
+        """
+        Save the figure to file.
+        The default directory for writing the file is the same as the
+        directory where the script or notebook is running.
+
+        Parameters
+        ----------
+        filename:
+            Name of the output file. Possible file extensions are ``.jpg``, ``.png``,
+            ``.svg``, and ``.pdf``.
+        """
+        self.fig.savefig(filename, **{**{'bbox_inches': 'tight'}, **kwargs})
+
+    def show(self):
+        """
+        Make a call to Matplotlib's underlying ``show`` function.
+        """
+        self.fig.show()
+
+    def __add__(self, other: Tiled) -> Tiled:
+        if not isinstance(other, self.__class__):
+            t = Tiled(1, 1)
+            t[0, 0] = other
+            other = t
+
+        out = Tiled(nrows=max(self.nrows, other.nrows), ncols=self.ncols + other.ncols)
+        for inds, view in self._history:
+            out[inds] = view
+        for inds, view in other._history:
+            out[inds[0], inds[1] + self.ncols] = view
+        return out
+
+    def __truediv__(self, other: Tiled) -> Tiled:
+        if not isinstance(other, self.__class__):
+            t = Tiled(1, 1)
+            t[0, 0] = other
+            other = t
+
+        out = Tiled(nrows=self.nrows + other.nrows, ncols=max(self.ncols, other.ncols))
+        for inds, view in self._history:
+            out[inds] = view
+        for inds, view in other._history:
+            out[inds[0] + self.nrows, inds[1]] = view
+        return out
+
+
+def hstack(left: Union[Tiled, FigureLike], right: Union[Tiled, FigureLike]) -> Tiled:
+    """
+    Display two views side by side.
+
+    Parameters
+    ----------
+    left:
+        The view to display on the left.
+    right:
+        The view to display on the right.
+    """
+    left_tiled = isinstance(left, Tiled)
+    right_tiled = isinstance(right, Tiled)
+    if (not left_tiled) and (not right_tiled):
+        out = Tiled(1, 2)
+        out[0, 0] = left
+        out[0, 1] = right
+        return out
+    elif left_tiled:
+        t = Tiled(1, 1)
+        t[0, 0] = right
+        return left + t
+    else:
+        t = Tiled(1, 1)
+        t[0, 0] = left
+        return t + right
+
+
+def vstack(top: Union[Tiled, FigureLike], bottom: Union[Tiled, FigureLike]) -> Tiled:
+    """
+    Display two views on top of each other.
+
+    Parameters
+    ----------
+    top:
+        The view to display on the top.
+    bottom:
+        The view to display on the bottom.
+    """
+    top_tiled = isinstance(top, Tiled)
+    bottom_tiled = isinstance(bottom, Tiled)
+    if (not top_tiled) and (not bottom_tiled):
+        out = Tiled(2, 1)
+        out[0, 0] = top
+        out[1, 0] = bottom
+        return out
+    elif top_tiled:
+        t = Tiled(1, 1)
+        t[0, 0] = bottom
+        return top / t
+    else:
+        t = Tiled(1, 1)
+        t[0, 0] = top
+        return t / bottom
diff --git a/src/plopp/backends/matplotlib/utils.py b/src/plopp/backends/matplotlib/utils.py
index 137942a..6656b72 100644
--- a/src/plopp/backends/matplotlib/utils.py
+++ b/src/plopp/backends/matplotlib/utils.py
@@ -8,6 +8,8 @@ from typing import Literal
 import matplotlib as mpl
 from matplotlib.pyplot import Figure
 
+from ..protocols import FigureLike
+
 
 def fig_to_bytes(fig: Figure, form: Literal['png', 'svg'] = 'png') -> bytes:
     """
@@ -92,3 +94,14 @@ def is_sphinx_build() -> bool:
     if hasattr(meta, "to_dict"):
         meta = meta.to_dict()
     return meta.get("scipp_sphinx_build", False)
+
+
+def copy_figure(fig: FigureLike, **kwargs) -> FigureLike:
+    out = fig.__class__(
+        fig._fig_constructor,
+        *fig._args,
+        **{**fig._kwargs, **kwargs},
+    )
+    for prop in ('xrange', 'yrange', 'xscale', 'yscale', 'title', 'grid'):
+        setattr(out.canvas, prop, getattr(fig.canvas, prop))
+    return out
diff --git a/src/plopp/backends/plotly/figure.py b/src/plopp/backends/plotly/figure.py
index 2a7473a..b12e8c3 100644
--- a/src/plopp/backends/plotly/figure.py
+++ b/src/plopp/backends/plotly/figure.py
@@ -11,7 +11,7 @@ class Figure(VBox):
     Create an interactive figure to represent one-dimensional data.
     """
 
-    def __init__(self, *args, FigConstructor, **kwargs):
+    def __init__(self, FigConstructor, *args, **kwargs):
         self._fig = FigConstructor(*args, **kwargs)
         self.toolbar = make_toolbar_canvas2d(canvas=self._fig.canvas)
         self.left_bar = VBar([self.toolbar])
@@ -35,10 +35,6 @@ class Figure(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/protocols.py b/src/plopp/backends/protocols.py
new file mode 100644
index 0000000..8caa7ef
--- /dev/null
+++ b/src/plopp/backends/protocols.py
@@ -0,0 +1,160 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+from __future__ import annotations
+
+from typing import Dict, Protocol, Tuple
+
+import scipp as sc
+
+from ..core import Node
+
+
+class CanvasLike(Protocol):
+    def autoscale(self) -> None:
+        ...
+
+    def draw(self) -> None:
+        ...
+
+    def save(self) -> None:
+        ...
+
+    def crop(self, **limits) -> None:
+        ...
+
+    @property
+    def empty(self) -> bool:
+        ...
+
+    @property
+    def title(self) -> str:
+        ...
+
+    @title.setter
+    def title(self, title: str) -> None:
+        ...
+
+    @property
+    def xlabel(self) -> str:
+        ...
+
+    @xlabel.setter
+    def xlabel(self, xlabel: str) -> None:
+        ...
+
+    @property
+    def ylabel(self) -> str:
+        ...
+
+    @ylabel.setter
+    def ylabel(self, ylabel: str) -> None:
+        ...
+
+    @property
+    def xscale(self) -> str:
+        ...
+
+    @xscale.setter
+    def xscale(self, xscale: str) -> None:
+        ...
+
+    @property
+    def yscale(self) -> str:
+        ...
+
+    @yscale.setter
+    def yscale(self, yscale: str) -> None:
+        ...
+
+    @property
+    def xmin(self) -> float:
+        ...
+
+    @xmin.setter
+    def xmin(self, xmin: float) -> None:
+        ...
+
+    @property
+    def xmax(self) -> float:
+        ...
+
+    @xmax.setter
+    def xmax(self, xmax: float) -> None:
+        ...
+
+    @property
+    def ymin(self) -> float:
+        ...
+
+    @ymin.setter
+    def ymin(self, ymin: float) -> None:
+        ...
+
+    @property
+    def ymax(self) -> float:
+        ...
+
+    @ymax.setter
+    def ymax(self, ymax: float) -> None:
+        ...
+
+    @property
+    def xrange(self) -> Tuple[float, float]:
+        ...
+
+    @xrange.setter
+    def xrange(self, xrange: Tuple[float, float]) -> None:
+        ...
+
+    @property
+    def yrange(self) -> Tuple[float, float]:
+        ...
+
+    @yrange.setter
+    def yrange(self, yrange: Tuple[float, float]) -> None:
+        ...
+
+    def logx(self) -> None:
+        ...
+
+    def logy(self) -> None:
+        ...
+
+
+class ArtistLike(Protocol):
+    def update(self, new_values: sc.DataArray) -> None:
+        ...
+
+
+class FigureLike(Protocol):
+    @property
+    def canvas(self) -> CanvasLike:
+        ...
+
+    @property
+    def artists(self) -> Dict[str, ArtistLike]:
+        ...
+
+    @property
+    def graph_nodes(self) -> Dict[str, Node]:
+        ...
+
+    @property
+    def id(self) -> str:
+        ...
+
+    def crop(self, **limits) -> None:
+        ...
+
+    def save(self, filename: str, **kwargs) -> None:
+        ...
+
+    def update(self, *args, **kwargs) -> None:
+        ...
+
+    def notify_view(self, *args, **kwargs) -> None:
+        ...
+
+    def copy(self, **kwargs) -> FigureLike:
+        ...
diff --git a/src/plopp/backends/pythreejs/figure.py b/src/plopp/backends/pythreejs/figure.py
index f3befb2..fe14780 100644
--- a/src/plopp/backends/pythreejs/figure.py
+++ b/src/plopp/backends/pythreejs/figure.py
@@ -13,7 +13,7 @@ class Figure(VBox):
     Create a figure to represent three-dimensional data.
     """
 
-    def __init__(self, *args, FigConstructor, **kwargs):
+    def __init__(self, FigConstructor, *args, **kwargs):
         self._fig = FigConstructor(*args, **kwargs)
         self.toolbar = make_toolbar_canvas3d(
             canvas=self._fig.canvas, colormapper=self._fig.colormapper
diff --git a/src/plopp/graphics/__init__.py b/src/plopp/graphics/__init__.py
index 3fb730c..d3653cc 100644
--- a/src/plopp/graphics/__init__.py
+++ b/src/plopp/graphics/__init__.py
@@ -9,3 +9,4 @@ from .figimage import FigImage
 from .figline import FigLine
 from .figscatter3d import FigScatter3d
 from .figure import figure1d, figure2d, figure3d
+from .tiled import tiled
diff --git a/src/plopp/graphics/figure.py b/src/plopp/graphics/figure.py
index 7a306ef..dda7dcb 100644
--- a/src/plopp/graphics/figure.py
+++ b/src/plopp/graphics/figure.py
@@ -39,7 +39,7 @@ def figure1d(*args, style: Literal['line'] = 'line', **kwargs):
     if style == 'line':
         from .figline import FigLine
 
-        return backends.figure1d(*args, FigConstructor=FigLine, **kwargs)
+        return backends.figure1d(FigLine, *args, **kwargs)
 
     raise ValueError(f'Unsupported style={style} for figure1d.')
 
@@ -71,7 +71,7 @@ def figure2d(*args, style: Literal['image'] = 'image', **kwargs):
     if style == 'image':
         from .figimage import FigImage
 
-        return backends.figure2d(*args, FigConstructor=FigImage, **kwargs)
+        return backends.figure2d(FigImage, *args, **kwargs)
 
     raise ValueError(f'Unsupported style={style} for figure2d.')
 
@@ -103,6 +103,6 @@ def figure3d(*args, style: Literal['scatter'] = 'scatter', **kwargs):
     if style == 'scatter':
         from .figscatter3d import FigScatter3d
 
-        return backends.figure3d(*args, FigConstructor=FigScatter3d, **kwargs)
+        return backends.figure3d(FigScatter3d, *args, **kwargs)
 
     raise ValueError(f'Unsupported style={style} for figure3d.')
diff --git a/src/plopp/graphics/tiled.py b/src/plopp/graphics/tiled.py
new file mode 100644
index 0000000..6aaa90c
--- /dev/null
+++ b/src/plopp/graphics/tiled.py
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+# from typing import Literal
+
+from .. import backends
+
+
+def tiled(nrows: int, ncols: int, **kwargs):
+    """ """
+    return backends.tiled(nrows=nrows, ncols=ncols, **kwargs)


Test patch:
diff --git a/tests/backends/matplotlib/mpl_interactive_test.py b/tests/backends/matplotlib/mpl_interactive_test.py
index ea6a112..8637e0c 100644
--- a/tests/backends/matplotlib/mpl_interactive_test.py
+++ b/tests/backends/matplotlib/mpl_interactive_test.py
@@ -10,26 +10,24 @@ from plopp.graphics.figline import FigLine
 
 def test_logx_1d_toolbar_button(use_ipympl):
     da = data_array(ndim=1)
-    fig = InteractiveFig(Node(da), FigConstructor=FigLine, scale={'xx': 'log'})
+    fig = InteractiveFig(FigLine, Node(da), scale={'xx': 'log'})
     assert fig.toolbar['logx'].value
 
 
 def test_logy_1d_toolbar_button(use_ipympl):
     da = data_array(ndim=1)
-    fig = InteractiveFig(Node(da), FigConstructor=FigLine, norm='log')
+    fig = InteractiveFig(FigLine, Node(da), norm='log')
     assert fig.toolbar['logy'].value
 
 
 def test_logxy_2d_toolbar_buttons(use_ipympl):
     da = data_array(ndim=2)
-    fig = InteractiveFig(
-        Node(da), FigConstructor=FigImage, scale={'xx': 'log', 'yy': 'log'}
-    )
+    fig = InteractiveFig(FigImage, Node(da), scale={'xx': 'log', 'yy': 'log'})
     assert fig.toolbar['logx'].value
     assert fig.toolbar['logy'].value
 
 
 def test_log_norm_2d_toolbar_button(use_ipympl):
     da = data_array(ndim=2)
-    fig = InteractiveFig(Node(da), FigConstructor=FigImage, norm='log')
+    fig = InteractiveFig(FigImage, Node(da), norm='log')
     assert fig.toolbar['lognorm'].value
diff --git a/tests/backends/matplotlib/mpl_tiled_test.py b/tests/backends/matplotlib/mpl_tiled_test.py
new file mode 100644
index 0000000..aa67b19
--- /dev/null
+++ b/tests/backends/matplotlib/mpl_tiled_test.py
@@ -0,0 +1,185 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+
+from plopp.backends.matplotlib.tiled import Tiled
+from plopp.data.testing import data_array
+
+
+def test_side_by_side():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = Tiled(nrows=1, ncols=2)
+    tiled[0, 0] = da1.plot()
+    tiled[0, 1] = da2.plot()
+    assert tiled.nrows == 1
+    assert tiled.ncols == 2
+
+
+def test_top_bottom():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = Tiled(nrows=2, ncols=1)
+    tiled[0, 0] = da1.plot()
+    tiled[1, 0] = da2.plot()
+    assert tiled.nrows == 2
+    assert tiled.ncols == 1
+
+
+def test_grid():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = Tiled(nrows=2, ncols=2)
+    tiled[0, 0] = da1.plot()
+    tiled[0, 1] = da2.plot()
+    tiled[1, 0] = da1.plot()
+    tiled[1, 1] = da2.plot()
+    assert tiled.nrows == 2
+    assert tiled.ncols == 2
+
+
+def test_range():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = Tiled(nrows=1, ncols=3)
+    tiled[0, :2] = da1.plot()
+    tiled[0, 2] = da2.plot()
+    assert tiled.nrows == 1
+    assert tiled.ncols == 3
+
+
+def test_add():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    da4 = data_array(ndim=2, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=2)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled2 = Tiled(nrows=1, ncols=2)
+    tiled2[0, 0] = da3.plot()
+    tiled2[0, 1] = da4.plot()
+    tiled = tiled1 + tiled2
+    assert tiled.nrows == 1
+    assert tiled.ncols == 4
+
+
+def test_add_tiled_figure():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=2)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled = tiled1 + da3.plot()
+    assert tiled.nrows == 1
+    assert tiled.ncols == 3
+
+
+def test_add_figure_tiled():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=2)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled = da3.plot() + tiled1
+    assert tiled.nrows == 1
+    assert tiled.ncols == 3
+
+
+def test_divide():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    da4 = data_array(ndim=2, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=2)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled2 = Tiled(nrows=1, ncols=2)
+    tiled2[0, 0] = da3.plot()
+    tiled2[0, 1] = da4.plot()
+    tiled = tiled1 / tiled2
+    assert tiled.nrows == 2
+    assert tiled.ncols == 2
+
+
+def test_divide_tiled_figure():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    da4 = data_array(ndim=2, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=3)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled1[0, 2] = da3.plot()
+    tiled = tiled1 / da4.plot()
+    assert tiled.nrows == 2
+    assert tiled.ncols == 3
+
+
+def test_divide_figure_tiled():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    da4 = data_array(ndim=2, binedges=True)
+    tiled1 = Tiled(nrows=1, ncols=3)
+    tiled1[0, 0] = da1.plot()
+    tiled1[0, 1] = da2.plot()
+    tiled1[0, 2] = da3.plot()
+    tiled = da4.plot() / tiled1
+    assert tiled.nrows == 2
+    assert tiled.ncols == 3
+
+
+def test_figure_add():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = da1.plot() + da2.plot()
+    assert tiled.nrows == 1
+    assert tiled.ncols == 2
+
+
+def test_figure_divide():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    tiled = da1.plot() / da2.plot()
+    assert tiled.nrows == 2
+    assert tiled.ncols == 1
+
+
+def test_figure_operators():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=2)
+    da3 = data_array(ndim=1, binedges=True)
+    da4 = data_array(ndim=2, binedges=True)
+    tiled = (da1.plot() + da2.plot()) / (da3.plot() + da4.plot())
+    assert tiled.nrows == 2
+    assert tiled.ncols == 2
+
+
+def test_tiled_keeps_figure_kwargs():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=1) * 3.3
+    p1 = da1.plot(grid=True, title="My Title", vmin=-2.3, vmax=10)
+    p2 = da2.plot(norm='log')
+    tiled = p1 + p2
+    assert tiled[0, 0].canvas.grid
+    assert tiled[0, 0].canvas.title == "My Title"
+    assert tiled[0, 0].canvas.ymin == -2.3
+    assert tiled[0, 0].canvas.ymax == 10
+    assert tiled[0, 1].canvas.yscale == 'log'
+
+
+def test_tiled_keeps_figure_props():
+    da1 = data_array(ndim=1)
+    da2 = data_array(ndim=1) * 3.3
+    p1 = da1.plot()
+    p2 = da2.plot()
+    p1.canvas.logy()
+    p2.canvas.logx()
+    tiled = p1 + p2
+    assert tiled[0, 0].canvas.xscale == 'linear'
+    assert tiled[0, 0].canvas.yscale == 'log'
+    assert tiled[0, 1].canvas.xscale == 'log'
+    assert tiled[0, 1].canvas.yscale == 'linear'
diff --git a/tests/backends/matplotlib/mpl_utils_test.py b/tests/backends/matplotlib/mpl_utils_test.py
new file mode 100644
index 0000000..94dfd23
--- /dev/null
+++ b/tests/backends/matplotlib/mpl_utils_test.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
+
+from plopp import Node
+from plopp.backends.matplotlib.interactive import InteractiveFig
+from plopp.backends.matplotlib.static import StaticFig
+from plopp.backends.matplotlib.utils import copy_figure
+from plopp.data.testing import data_array
+from plopp.graphics.figline import FigLine
+
+
+def do_test_copy(Fig):
+    da = data_array(ndim=1)
+    original = Fig(FigLine, Node(da))
+    copy = copy_figure(original)
+    assert original.graph_nodes.keys() == copy.graph_nodes.keys()
+    assert original.artists.keys() == copy.artists.keys()
+
+
+def do_test_copy_keeps_kwargs(Fig):
+    da = data_array(ndim=1)
+    original = Fig(
+        FigLine,
+        Node(da),
+        scale={'xx': 'log'},
+        norm='log',
+        grid=True,
+        title='A nice title',
+    )
+    copy = copy_figure(original)
+    assert copy.canvas.xscale == 'log'
+    assert copy.canvas.yscale == 'log'
+    assert copy.canvas.grid
+    assert copy.canvas.title == 'A nice title'
+
+
+def test_copy_static():
+    do_test_copy(StaticFig)
+
+
+def test_copy_static_keeps_kwargs():
+    do_test_copy_keeps_kwargs(StaticFig)
+
+
+def test_copy_interactive(use_ipympl):
+    do_test_copy(InteractiveFig)
+
+
+def test_copy_interactive_keeps_kwargs(use_ipympl):
+    do_test_copy_keeps_kwargs(InteractiveFig)
diff --git a/tests/backends/plotly/plotly_figure_test.py b/tests/backends/plotly/plotly_figure_test.py
index 85088cd..503306d 100644
--- a/tests/backends/plotly/plotly_figure_test.py
+++ b/tests/backends/plotly/plotly_figure_test.py
@@ -13,18 +13,18 @@ pytest.importorskip("plotly")
 
 def test_creation():
     da = data_array(ndim=1)
-    fig = Figure(pp.Node(da), FigConstructor=FigLine)
+    fig = Figure(FigLine, pp.Node(da))
     assert fig.canvas.xlabel == f'xx [{da.coords["xx"].unit}]'
     assert fig.canvas.ylabel == f'[{da.unit}]'
 
 
 def test_logx_1d_toolbar_button():
     da = data_array(ndim=1)
-    fig = Figure(pp.Node(da), FigConstructor=FigLine, scale={'xx': 'log'})
+    fig = Figure(FigLine, pp.Node(da), scale={'xx': 'log'})
     assert fig.toolbar['logx'].value
 
 
 def test_logy_1d_toolbar_button():
     da = data_array(ndim=1)
-    fig = Figure(pp.Node(da), FigConstructor=FigLine, norm='log')
+    fig = Figure(FigLine, pp.Node(da), norm='log')
     assert fig.toolbar['logy'].value
diff --git a/tests/backends/pythreejs/pythreejs_figure_test.py b/tests/backends/pythreejs/pythreejs_figure_test.py
index 6528bfb..fb5366a 100644
--- a/tests/backends/pythreejs/pythreejs_figure_test.py
+++ b/tests/backends/pythreejs/pythreejs_figure_test.py
@@ -14,13 +14,13 @@ from plopp.graphics.figscatter3d import FigScatter3d
 
 def test_log_norm_3d_toolbar_button():
     da = scatter()
-    fig = Figure(Node(da), FigConstructor=FigScatter3d, x='x', y='y', z='z', norm='log')
+    fig = Figure(FigScatter3d, Node(da), x='x', y='y', z='z', norm='log')
     assert fig.toolbar['lognorm'].value
 
 
 def test_save_to_html():
     da = scatter()
-    fig = Figure(Node(da), FigConstructor=FigScatter3d, x='x', y='y', z='z', norm='log')
+    fig = Figure(FigScatter3d, Node(da), x='x', y='y', z='z', norm='log')
     with tempfile.TemporaryDirectory() as path:
         fname = os.path.join(path, 'plopp_fig3d.html')
         fig.save(filename=fname)
@@ -29,6 +29,6 @@ def test_save_to_html():
 
 def test_save_to_html_with_bad_extension_raises():
     da = scatter()
-    fig = Figure(Node(da), FigConstructor=FigScatter3d, x='x', y='y', z='z', norm='log')
+    fig = Figure(FigScatter3d, Node(da), x='x', y='y', z='z', norm='log')
     with pytest.raises(ValueError, match=r'File extension must be \.html'):
         fig.save(filename='plopp_fig3d.png')