Repository: holoviz/holoviews
Pull request #5589 (2023-01-12 10:00:19)

Add changelog for v1.15.4 release
It needs https://github.com/holoviz/holoviews/pull/5588 to be merged.

And need to be exported to `docs/release.rst`.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6bfa1347..c40155bc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,51 @@
+Version 1.15.4
+==============
+**January 16, 2023**
+
+This release contains a small number of enhancements and important bug fixes.
+Many thanks to our new contributors @mmorys, @jj-github-jj, and @sandhujasmine, but also our returning contributors @droumis, @jlstevens, @MarcSkovMadsen, @maximlt, @philippjfr, @stanwest, and @Hoxbro.
+
+
+Enhancements:
+
+- Make lasso select mask values using a Dask-compatible method ([#5568](https://github.com/holoviz/holoviews/pull/5568))
+- Make plotly legend group unique ([#5570](https://github.com/holoviz/holoviews/pull/5570))
+- Set pan and wheel_zoom as the default Bokeh active tools ([#5480](https://github.com/holoviz/holoviews/pull/5480))
+- Enable rendering colorbars on bokeh `GraphPlot`s ([#5585](https://github.com/holoviz/holoviews/pull/5585))
+- Add Plotly `Scatter3d` documentation and fix colorbar title ([#5418](https://github.com/holoviz/holoviews/pull/5418))
+
+Bug fixes:
+
+- Only trigger range-update once in callbacks ([#5558](https://github.com/holoviz/holoviews/pull/5558))
+- Ensure dynamically created subplots can be updated ([#5555](https://github.com/holoviz/holoviews/pull/5555))
+- Fix start of stack-level in deprecations ([#5569](https://github.com/holoviz/holoviews/pull/5569))
+- When sorting colormap records, replace None with an empty string ([#5539](https://github.com/holoviz/holoviews/pull/5539))
+- Fix annotator in Geoviews by adding deserialization of non-deserialized base64 data ([#5587](https://github.com/holoviz/holoviews/pull/5587))
+- Fix `hv.Empty` not working in `AdjointLayout` plot ([#5584](https://github.com/holoviz/holoviews/pull/5584))
+- Check for categorical data to histogram ([#5540](https://github.com/holoviz/holoviews/pull/5540))
+- Fix `clim_percentile` ([#5495](https://github.com/holoviz/holoviews/pull/5495))
+
+Compatibility:
+
+- Compatibility with Shapely 2.0 ([#5561](https://github.com/holoviz/holoviews/pull/5561))
+- Compatibility with Numpy 1.24 ([#5581](https://github.com/holoviz/holoviews/pull/5581))
+- Compatibility with Ibis 4.0 ([#5588](https://github.com/holoviz/holoviews/pull/5588))
+
+Documentation:
+
+- Installation instructions update ([#5562](https://github.com/holoviz/holoviews/pull/5562))
+- Use OSM for reference tile source in notebook documentation ([#5536](https://github.com/holoviz/holoviews/pull/5536))
+- Enhance Tiles example notebook ([#5563](https://github.com/holoviz/holoviews/pull/5563))
+
+Maintenance:
+
+- Various fixes and general maintenance of the CI ([#5384](https://github.com/holoviz/holoviews/pull/5384), [#5573](https://github.com/holoviz/holoviews/pull/5573), [#5576](https://github.com/holoviz/holoviews/pull/5576), [#5582](https://github.com/holoviz/holoviews/pull/5582))
+- Updated codebase to modern Python conventions ([#5509](https://github.com/holoviz/holoviews/pull/5509), [#5577](https://github.com/holoviz/holoviews/pull/5577))
+- Renamed `master` branch to `main` ([#5579](https://github.com/holoviz/holoviews/pull/5579))
+- Update binder link and dependency pinning ([#5583](https://github.com/holoviz/holoviews/pull/5583))
+- Update copyright to only contain start year ([#5580](https://github.com/holoviz/holoviews/pull/5580))
+
+
 Version 1.15.3
 ==============
 **December 6, 2022**
diff --git a/doc/releases.rst b/doc/releases.rst
index 4b65b5dcb..9afadf376 100644
--- a/doc/releases.rst
+++ b/doc/releases.rst
@@ -4,6 +4,85 @@ Releases
 Version 1.15
 ~~~~~~~~~~~~
 
+Version 1.15.4
+**************
+
+**January 16, 2023**
+
+This release contains a small number of enhancements and important bug
+fixes. Many thanks to our new contributors @mmorys, @jj-github-jj, and
+@sandhujasmine, but also our returning contributors @droumis,
+@jlstevens, @MarcSkovMadsen, @maximlt, @philippjfr, @stanwest, and
+@Hoxbro.
+
+Enhancements:
+
+-  Make lasso select mask values using a Dask-compatible method
+   (`#5568 <https://github.com/holoviz/holoviews/pull/5568>`__)
+-  Make plotly legend group unique
+   (`#5570 <https://github.com/holoviz/holoviews/pull/5570>`__)
+-  Set pan and wheel_zoom as the default Bokeh active tools
+   (`#5480 <https://github.com/holoviz/holoviews/pull/5480>`__)
+-  Enable rendering colorbars on bokeh ``GraphPlot``\ s
+   (`#5585 <https://github.com/holoviz/holoviews/pull/5585>`__)
+-  Add Plotly ``Scatter3d`` documentation and fix colorbar title
+   (`#5418 <https://github.com/holoviz/holoviews/pull/5418>`__)
+
+Bug fixes:
+
+-  Only trigger range-update once in callbacks
+   (`#5558 <https://github.com/holoviz/holoviews/pull/5558>`__)
+-  Ensure dynamically created subplots can be updated
+   (`#5555 <https://github.com/holoviz/holoviews/pull/5555>`__)
+-  Fix start of stack-level in deprecations
+   (`#5569 <https://github.com/holoviz/holoviews/pull/5569>`__)
+-  When sorting colormap records, replace None with an empty string
+   (`#5539 <https://github.com/holoviz/holoviews/pull/5539>`__)
+-  Fix annotator in Geoviews by adding deserialization of
+   non-deserialized base64 data
+   (`#5587 <https://github.com/holoviz/holoviews/pull/5587>`__)
+-  Fix ``hv.Empty`` not working in ``AdjointLayout`` plot
+   (`#5584 <https://github.com/holoviz/holoviews/pull/5584>`__)
+-  Check for categorical data to histogram
+   (`#5540 <https://github.com/holoviz/holoviews/pull/5540>`__)
+-  Fix ``clim_percentile``
+   (`#5495 <https://github.com/holoviz/holoviews/pull/5495>`__)
+
+Compatibility:
+
+-  Compatibility with Shapely 2.0
+   (`#5561 <https://github.com/holoviz/holoviews/pull/5561>`__)
+-  Compatibility with Numpy 1.24
+   (`#5581 <https://github.com/holoviz/holoviews/pull/5581>`__)
+-  Compatibility with Ibis 4.0
+   (`#5588 <https://github.com/holoviz/holoviews/pull/5588>`__)
+
+Documentation:
+
+-  Installation instructions update
+   (`#5562 <https://github.com/holoviz/holoviews/pull/5562>`__)
+-  Use OSM for reference tile source in notebook documentation
+   (`#5536 <https://github.com/holoviz/holoviews/pull/5536>`__)
+-  Enhance Tiles example notebook
+   (`#5563 <https://github.com/holoviz/holoviews/pull/5563>`__)
+
+Maintenance:
+
+-  Various fixes and general maintenance of the CI
+   (`#5384 <https://github.com/holoviz/holoviews/pull/5384>`__,
+   `#5573 <https://github.com/holoviz/holoviews/pull/5573>`__,
+   `#5576 <https://github.com/holoviz/holoviews/pull/5576>`__,
+   `#5582 <https://github.com/holoviz/holoviews/pull/5582>`__)
+-  Updated codebase to modern Python conventions
+   (`#5509 <https://github.com/holoviz/holoviews/pull/5509>`__,
+   `#5577 <https://github.com/holoviz/holoviews/pull/5577>`__)
+-  Renamed ``master`` branch to ``main``
+   (`#5579 <https://github.com/holoviz/holoviews/pull/5579>`__)
+-  Update binder link and dependency pinning
+   (`#5583 <https://github.com/holoviz/holoviews/pull/5583>`__)
+-  Update copyright to only contain start year
+   (`#5580 <https://github.com/holoviz/holoviews/pull/5580>`__)
+
 Version 1.15.3
 **************
 
@@ -15,22 +94,22 @@ adds support for Python 3.11. Many thanks to our maintainers
 
 Bug Fixes:
 
-  - Fix for empty opts warning and incorrect clearing semantics
-    (`#5496 <https://github.com/holoviz/holoviews/pull/5496>`__)
-  - Fix potential race condition in the Options system
-    (`#5535 <https://github.com/holoviz/holoviews/pull/5535>`__)
+- Fix for empty opts warning and incorrect clearing semantics
+  (`#5496 <https://github.com/holoviz/holoviews/pull/5496>`__)
+- Fix potential race condition in the Options system
+  (`#5535 <https://github.com/holoviz/holoviews/pull/5535>`__)
 
 Enhancements:
 
-  - Add support to Python 3.11
-    (`#5513 <https://github.com/holoviz/holoviews/pull/5513>`__)
-  - Cleanup the top `__init__` module
-    (`#5516 <https://github.com/holoviz/holoviews/pull/5516>`__)
+- Add support to Python 3.11
+  (`#5513 <https://github.com/holoviz/holoviews/pull/5513>`__)
+- Cleanup the top `__init__` module
+  (`#5516 <https://github.com/holoviz/holoviews/pull/5516>`__)
 
 Documentation:
 
-  - Fixes to release notes and CHANGELOG
-    (`#5506 <https://github.com/holoviz/holoviews/pull/5506>`__)
+- Fixes to release notes and CHANGELOG
+  (`#5506 <https://github.com/holoviz/holoviews/pull/5506>`__)
 
 
 Version 1.15.2
diff --git a/examples/reference/elements/plotly/Scatter3D.ipynb b/examples/reference/elements/plotly/Scatter3D.ipynb
index 92b89aef8..fd5fa3102 100644
--- a/examples/reference/elements/plotly/Scatter3D.ipynb
+++ b/examples/reference/elements/plotly/Scatter3D.ipynb
@@ -20,6 +20,7 @@
    "outputs": [],
    "source": [
     "import numpy as np\n",
+    "import pandas as pd\n",
     "import holoviews as hv\n",
     "from holoviews import dim, opts\n",
     "\n",
@@ -30,7 +31,11 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "``Scatter3D`` represents three-dimensional coordinates which may be colormapped or scaled in size according to a value. They are therefore very similar to [``Points``](Points.ipynb) and [``Scatter``](Scatter.ipynb) types but have one additional coordinate dimension. Like other 3D elements the camera angle can be controlled using ``azimuth``, ``elevation`` and ``distance`` plot options:"
+    "``Scatter3D`` plots represents three-dimensional coordinates which are specified as key dimensions (`kdims`, default `kdims` are x, y, z). The points can be given a `marker`, `color` and `size`. Additionally a `colorbar` can be added.\n",
+    "\n",
+    "``Scatter3D`` plots are therefore very similar to [``Points``](Points.ipynb) and [``Scatter``](Scatter.ipynb) types, but have one additional coordinate dimension.\n",
+    "\n",
+    "Like other 3D elements the camera angle can be controlled using `azimuth`, `elevation` and `distance` plot options"
    ]
   },
   {
@@ -40,9 +45,27 @@
    "outputs": [],
    "source": [
     "y,x = np.mgrid[-5:5, -5:5] * 0.1\n",
-    "heights = np.sin(x**2+y**2)\n",
-    "hv.Scatter3D((x.flat, y.flat, heights.flat)).opts(\n",
-    "    cmap='fire', color='z', size=5)"
+    "z=np.sin(x**2+y**2)\n",
+    "\n",
+    "hv.Scatter3D((x.flat,y.flat,z.flat)).opts(cmap='fire', color='z', size=5, colorbar=True, height=600, width=600)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also provide your data in a container and specify the `kdims` to use."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "data = pd.DataFrame(dict(lat=x.flat, lon=y.flat, height=z.flat))\n",
+    "\n",
+    "hv.Scatter3D(data, kdims=[\"lat\", \"lon\", \"height\"]).opts(cmap='blues', color='height', size=3, colorbar=True, height=600, width=600, colorbar_opts={'title': 'height (m)'}, marker=\"diamond\")"
    ]
   },
   {
@@ -59,7 +82,7 @@
    "outputs": [],
    "source": [
     "(hv.Scatter3D(np.random.randn(100,4), vdims='Size') * hv.Scatter3D(np.random.randn(100,4)+2, vdims='Size')).opts(\n",
-    "    opts.Scatter3D(size=(5+dim('Size'))*2, marker='diamond')\n",
+    "    opts.Scatter3D(size=(5+dim('Size'))*2, marker='diamond', height=600, width=600)\n",
     ")"
    ]
   },
@@ -69,6 +92,15 @@
    "source": [
     "For full documentation and the available style and plot options, use ``hv.help(hv.Scatter3D).``"
    ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "hv.help(hv.Scatter3D)"
+   ]
   }
  ],
  "metadata": {
@@ -78,5 +110,5 @@
   }
  },
  "nbformat": 4,
- "nbformat_minor": 1
+ "nbformat_minor": 4
 }
diff --git a/holoviews/core/data/ibis.py b/holoviews/core/data/ibis.py
index d17261bb1..c013c9bc5 100644
--- a/holoviews/core/data/ibis.py
+++ b/holoviews/core/data/ibis.py
@@ -13,7 +13,8 @@ from .util import cached
 
 try:
     import ibis
-    ibis_version =  Version(ibis.__version__)
+    ibis_version = Version(ibis.__version__)
+    ibis4 = ibis_version >= Version("4.0")
 except ImportError:
     pass
 
@@ -95,13 +96,19 @@ class IbisInterface(Interface):
     @cached
     def length(self, dataset):
         # Get the length by counting the length of an empty query.
-        return dataset.data[[]].count().execute()
+        if ibis4:
+            return dataset.data.count().execute()
+        else:
+            return dataset.data[[]].count().execute()
 
     @classmethod
     @cached
     def nonzero(cls, dataset):
         # Make an empty query to see if a row is returned.
-        return bool(len(dataset.data[[]].head(1).execute()))
+        if ibis4:
+            return bool(len(dataset.data.head(1).execute()))
+        else:
+            return bool(len(dataset.data[[]].head(1).execute()))
 
     @classmethod
     @cached
@@ -145,7 +152,12 @@ class IbisInterface(Interface):
         bins = [int(v) if bins.dtype.kind in 'iu' else float(v) for v in bins]
         binned = expr.bucket(bins).name('bucket')
         hist = numpy.zeros(len(bins)-1)
-        hist_bins = binned.value_counts().sort_by('bucket').execute()
+        if ibis4:
+            hist_bins = binned.value_counts().order_by('bucket').execute()
+        else:
+            # sort_by will be removed in Ibis 5.0
+            hist_bins = binned.value_counts().sort_by('bucket').execute()
+
         for b, v in zip(hist_bins['bucket'], hist_bins['count']):
             if numpy.isnan(b):
                 continue
@@ -172,7 +184,11 @@ class IbisInterface(Interface):
 
     @classmethod
     def sort(cls, dataset, by=[], reverse=False):
-        return dataset.data.sort_by([(dataset.get_dimension(x).name, not reverse) for x in by])
+        if ibis4:
+            return dataset.data.order_by([(dataset.get_dimension(x).name, not reverse) for x in by])
+        else:
+            # sort_by will be removed in Ibis 5.0
+            return dataset.data.sort_by([(dataset.get_dimension(x).name, not reverse) for x in by])
 
     @classmethod
     def redim(cls, dataset, dimensions):
@@ -194,10 +210,14 @@ class IbisInterface(Interface):
 
         if "hv_row_id__" in data.columns:
             return data
-        if cls.is_rowid_zero_indexed(data):
-            return data.mutate(hv_row_id__=data.rowid())
+
+        if ibis4:
+            return data.mutate(hv_row_id__=ibis.row_number())
         else:
-            return data.mutate(hv_row_id__=data.rowid() - 1)
+            if cls.is_rowid_zero_indexed(data):
+                return data.mutate(hv_row_id__=data.rowid())
+            else:
+                return data.mutate(hv_row_id__=data.rowid() - 1)
 
     @classmethod
     def iloc(cls, dataset, index):
@@ -231,12 +251,17 @@ class IbisInterface(Interface):
                 if rows.stop:
                     predicates += [data.hv_row_id__ < rows.stop]
 
-                return data.filter(predicates).drop(["hv_row_id__"])
+                data = data.filter(predicates)
         else:
             if not isinstance(rows, Iterable):
                 rows = [rows]
-            return data.filter([data.hv_row_id__.isin(rows)]).drop(["hv_row_id__"])
-        return data.drop(["hv_row_id__"])
+            data = data.filter([data.hv_row_id__.isin(rows)])
+
+        if ibis4:
+            return data.drop("hv_row_id__")
+        else:
+            # Passing a sequence of fields to `drop` will be removed in Ibis 5.0
+            return data.drop(["hv_row_id__"])
 
     @classmethod
     def unpack_scalar(cls, dataset, data):
@@ -244,7 +269,12 @@ class IbisInterface(Interface):
         Given a dataset object and data in the appropriate format for
         the interface, return a simple scalar.
         """
-        if len(data.columns) > 1 or data[[]].count().execute() != 1:
+        if ibis4:
+            count = data.count().execute()
+        else:
+            count = data[[]].count().execute()
+
+        if len(data.columns) > 1 or count != 1:
             return data
         return data.execute().iat[0, 0]
 
@@ -263,7 +293,11 @@ class IbisInterface(Interface):
         group_by = [d.name for d in index_dims]
 
         # execute a query against the table to find the unique groups.
-        groups = dataset.data.groupby(group_by).aggregate().execute()
+        if ibis4:
+            groups = dataset.data.group_by(group_by).aggregate().execute()
+        else:
+            # groupby will be removed in Ibis 5.0
+            groups = dataset.data.groupby(group_by).aggregate().execute()
 
         # filter each group based on the predicate defined.
         data = [
@@ -324,7 +358,14 @@ class IbisInterface(Interface):
                 selection_mask = numpy.where(selection_mask)[0]
             data = data.filter(
                 data["hv_row_id__"].isin(list(map(int, selection_mask)))
-            ).drop(["hv_row_id__"])
+            )
+
+            if ibis4:
+                data = data.drop("hv_row_id__")
+            else:
+                # Passing a sequence of fields to `drop` will be removed in Ibis 5.0
+                data = data.drop(["hv_row_id__"])
+
         elif selection_mask is not None and not (isinstance(selection_mask, list) and not selection_mask):
             data = data.filter(selection_mask)
 
@@ -420,7 +461,12 @@ class IbisInterface(Interface):
         }.get(function, function)
 
         if len(dimensions):
-            selection = new.groupby(columns)
+            if ibis4:
+                 selection = new.group_by(columns)
+            else:
+                # groupby will be removed in Ibis 5.0
+                selection = new.groupby(columns)
+
             if function is numpy.count_nonzero:
                 aggregation = selection.aggregate(
                     **{
diff --git a/holoviews/element/chart3d.py b/holoviews/element/chart3d.py
index c52a97715..49b91e910 100644
--- a/holoviews/element/chart3d.py
+++ b/holoviews/element/chart3d.py
@@ -70,8 +70,50 @@ class Scatter3D(Element3D, Points):
     """
     Scatter3D is a 3D element representing the position of a collection
     of coordinates in a 3D space. The key dimensions represent the
-    position of each coordinate along the x-, y- and z-axis while the
-    value dimensions can optionally supply additional information.
+    position of each coordinate along the x-, y- and z-axis.
+
+    Scatter3D is not available for the default Bokeh backend.
+
+    Example - Matplotlib
+    --------------------
+
+    .. code-block::
+
+        import holoviews as hv
+        from bokeh.sampledata.iris import flowers
+
+        hv.extension("matplotlib")
+
+        hv.Scatter3D(
+            flowers, kdims=["sepal_length", "sepal_width", "petal_length"]
+        ).opts(
+            color="petal_width",
+            alpha=0.7,
+            size=5,
+            cmap="fire",
+            marker='^'
+        )
+
+    Example - Plotly
+    ----------------
+
+    .. code-block::
+
+        import holoviews as hv
+        from bokeh.sampledata.iris import flowers
+
+        hv.extension("plotly")
+
+        hv.Scatter3D(
+            flowers, kdims=["sepal_length", "sepal_width", "petal_length"]
+        ).opts(
+            color="petal_width",
+            alpha=0.7,
+            size=5,
+            cmap="Portland",
+            colorbar=True,
+            marker="circle",
+        )
     """
 
     kdims = param.List(default=[Dimension('x'),
diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py
index 2b027c2a3..460f0085c 100644
--- a/holoviews/plotting/plotly/element.py
+++ b/holoviews/plotting/plotly/element.py
@@ -604,11 +604,19 @@ class ColorbarPlot(ElementPlot):
         opts = {}
         dim_name = dim_range_key(eldim)
         if self.colorbar:
-            if isinstance(eldim, dim):
-                title = str(eldim) if eldim.ops else str(eldim)[1:-1]
-            else:
-                title = eldim.pprint_label
-            opts['colorbar'] = dict(title=title, **self.colorbar_opts)
+            opts['colorbar'] = dict(**self.colorbar_opts)
+            if not "title" in opts['colorbar']:
+                if isinstance(eldim, dim):
+                    title = str(eldim)
+                    if eldim.ops:
+                        title = title
+                    elif title.startswith("dim('") and title.endswith("')"):
+                        title = title[5:-2]
+                    else:
+                        title = title[1:-1]
+                else:
+                    title = eldim.pprint_label
+                opts['colorbar']['title']=title
             opts['showscale'] = True
         else:
             opts['showscale'] = False
diff --git a/pyproject.toml b/pyproject.toml
index 489a78a99..1fde1007a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,15 +11,17 @@ filterwarnings = [
     # Numpy warning triggered in Bokeh, when timedelta or datetime numpy scalar are compared to a numerical scalar
     "ignore:elementwise comparison failed:DeprecationWarning:bokeh.core.property.bases",
     "ignore:dropping variables using `drop` will be deprecated; using drop_vars is encouraged:PendingDeprecationWarning:xarray.core.dataarray",
-    # 2022-12-27: Plotly don't use packagings version yet
+    # 2022-12: Plotly don't use packagings version yet
     "ignore:distutils Version classes are deprecated:DeprecationWarning:plotly.graph_objs",
-    # 2022-12-27: A weird warning which could be related to Datashader
+    # 2022-12: A weird warning which could be related to Datashader
     "ignore:Please use `append` from the:DeprecationWarning:pickle",
-    # 2022-12-27: Warnings which should be fixed in Panel
+    # 2022-12: Warnings which should be fixed in Panel
     "ignore:make_current is deprecated; start the event loop first:DeprecationWarning:panel.io.server",
-    # 2023-01-02: Numpy 1.24 warnings
+    # 2023-01: Numpy 1.24 warnings
     "ignore:`.+?` is a deprecated alias for `.+?`.:DeprecationWarning:bokeh",  # https://github.com/bokeh/bokeh/pull/12690
     "ignore:`.+?` is a deprecated alias for `.+?`.:DeprecationWarning:cupy",  # https://github.com/cupy/cupy/pull/7245
     "ignore:`.+?` is a deprecated alias for `.+?`.:DeprecationWarning:plotly.express.imshow_utils",  # https://github.com/plotly/plotly.py/pull/3997
     "ignore:`.+?` is a deprecated alias for `.+?`.:DeprecationWarning:skimage.util.dtype",  # https://github.com/scikit-image/scikit-image/pull/6637
+    # 2023-01: Sqlalchemy 2.0 warning:
+    "ignore: Deprecated API features detected:DeprecationWarning:ibis.backends.base.sql.alchemy",  # https://github.com/ibis-project/ibis/issues/5048
 ]
diff --git a/setup.py b/setup.py
index 4e7e4498e..f908c7773 100644
--- a/setup.py
+++ b/setup.py
@@ -49,7 +49,7 @@ extras_require['tests_core'] = [
 # of those.
 extras_require['tests'] = extras_require['tests_core'] + [
     'dask',
-    'ibis-framework <4',  # Mapped to ibis-sqlite in setup.cfg for conda
+    'ibis-framework',  # Mapped to ibis-sqlite in setup.cfg for conda
     'xarray >=0.10.4',
     'networkx',
     'shapely',


Test patch:
diff --git a/holoviews/tests/plotting/plotly/test_elementplot.py b/holoviews/tests/plotting/plotly/test_elementplot.py
index 213324302..5f1460c6b 100644
--- a/holoviews/tests/plotting/plotly/test_elementplot.py
+++ b/holoviews/tests/plotting/plotly/test_elementplot.py
@@ -1,6 +1,7 @@
 from collections import deque
 
 import numpy as np
+import pandas as pd
 
 from holoviews.core.spaces import DynamicMap
 from holoviews.element import Curve, Scatter3D, Path3D
@@ -223,3 +224,28 @@ class TestOverlayPlot(TestPlotlyPlot):
         scatter = Path3D([]) * Scatter3D([(10, 1, 2), (100, 2, 3), (1000, 3, 5)]).opts(zlabel='Z-Axis')
         state = self._get_plot_state(scatter)
         self.assertEqual(state['layout']['scene']['zaxis']['title']['text'], 'Z-Axis')
+
+class TestColorbarPlot(TestPlotlyPlot):
+    def test_base(self):
+        df = pd.DataFrame(np.random.random((10, 4)), columns=list("XYZT"))
+        scatter = Scatter3D(data=df)
+        state = self._get_plot_state(scatter)
+        assert "colorbar" not in state["data"][0]["marker"]
+
+    def test_colorbar(self):
+        df = pd.DataFrame(np.random.random((10, 4)), columns=list("XYZT"))
+        scatter = Scatter3D(data=df).opts(color="T", colorbar=True)
+        state = self._get_plot_state(scatter)
+        assert "colorbar" in state["data"][0]["marker"]
+        assert state["data"][0]["marker"]["colorbar"]["title"]["text"] == "T"
+
+    def test_colorbar_opts_title(self):
+        df = pd.DataFrame(np.random.random((10, 4)), columns=list("XYZT"))
+        scatter = Scatter3D(data=df).opts(
+            color="T",
+            colorbar=True,
+            colorbar_opts={"title": "some-title"}
+        )
+        state = self._get_plot_state(scatter)
+        assert "colorbar" in state["data"][0]["marker"]
+        assert state["data"][0]["marker"]["colorbar"]["title"]["text"] == "some-title"