Repository: holoviz/holoviews

Issue:
Plotly Scatter 3D not rendering colorbar title correctly
#### ALL software version info

python: 3.8.8 | packaged by conda-forge | (default, Feb 20 2021, 16:22:27) 
[GCC 9.3.0]
python-bits: 64
OS: Linux
OS-release: 5.4.0-1030-aws
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: C.UTF-8
LOCALE: en_US.UTF-8
Firefox: 88.0.1 (64-bit)
Conda: 4.10.0

pandas: 1.2.4
holoviews: 1.14.3
plotly: 4.14.3
jupyterlab: 2.2.6 
jupyterlab_server: 1.2.0

#### Description of expected behavior and the observed behavior

I am expecting that the colorbar title would be based on the column title that is designated as color in the options. However, it appears that something is not right, and instead of giving a clean title, it has `im(` prefix to the title. Ex. `im('temperature'`. Also, if I try to pass in `title` to `colorbar_opts` it gives me a `TypeError` saying that `title` already exist.

After reading the error message and digging into the code. I suspect this line is causing this issue:
https://github.com/holoviz/holoviews/blob/989c3c304a34e5043a52eba87dd056b6e48d9eea/holoviews/plotting/plotly/element.py#L603-L611

- `str(eldim)[1:-1] ` seems strange to me, and but this does result in the title issue above. If `eldim` is `dim('temperature')` doing `[1:-1]` would result in `im('temperature'`.
- Regarding optional title for `colorbar_opts` is there a reason that this is hardcoded in the code? I have made plots with the bokeh stuff, and that option is available. Seems like this behavior is confusing.

#### Complete, minimal, self-contained example code that reproduces the issue

```python
import pandas as pd
import holoviews as hv
from holoviews import opts

hv.extension('plotly')

df = pd.read_csv('https://raw.githubusercontent.com/cormorack/development/main/tempsf_test.csv', index_col='id')

scatter_opts = opts.Scatter3D(
    color='temperature', 
    cmap='viridis', 
    colorbar=True, 
    size=2,
    clim=(1, 7),

    # Adding colorbar_opts title will fail ...
    # colorbar_opts={'title': 'Temperature (degC)'}
)

hv.Scatter3D(data=df).opts(scatter_opts)
```

#### Stack traceback and/or browser JavaScript console output

<details>
<summary>Traceback when using colorbar_opts</summary>

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/opt/conda/envs/cava-data/lib/python3.8/site-packages/IPython/core/formatters.py in __call__(self, obj, include, exclude)
    968 
    969             if method is not None:
--> 970                 return method(include=include, exclude=exclude)
    971             return None
    972         else:

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/core/dimension.py in _repr_mimebundle_(self, include, exclude)
   1315         combined and returned.
   1316         """
-> 1317         return Store.render(self)
   1318 
   1319 

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/core/options.py in render(cls, obj)
   1403         data, metadata = {}, {}
   1404         for hook in hooks:
-> 1405             ret = hook(obj)
   1406             if ret is None:
   1407                 continue

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/ipython/display_hooks.py in pprint_display(obj)
    280     if not ip.display_formatter.formatters['text/plain'].pprint:
    281         return None
--> 282     return display(obj, raw_output=True)
    283 
    284 

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/ipython/display_hooks.py in display(obj, raw_output, **kwargs)
    250     elif isinstance(obj, (CompositeOverlay, ViewableElement)):
    251         with option_state(obj):
--> 252             output = element_display(obj)
    253     elif isinstance(obj, (Layout, NdLayout, AdjointLayout)):
    254         with option_state(obj):

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/ipython/display_hooks.py in wrapped(element)
    144         try:
    145             max_frames = OutputSettings.options['max_frames']
--> 146             mimebundle = fn(element, max_frames=max_frames)
    147             if mimebundle is None:
    148                 return {}, {}

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/ipython/display_hooks.py in element_display(element, max_frames)
    190         return None
    191 
--> 192     return render(element)
    193 
    194 

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/ipython/display_hooks.py in render(obj, **kwargs)
     66         renderer = renderer.instance(fig='png')
     67 
---> 68     return renderer.components(obj, **kwargs)
     69 
     70 

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/renderer.py in components(self, obj, fmt, comm, **kwargs)
    408                 doc = Document()
    409                 with config.set(embed=embed):
--> 410                     model = plot.layout._render_model(doc, comm)
    411                 if embed:
    412                     return render_model(model, comm)

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/viewable.py in _render_model(self, doc, comm)
    425         if comm is None:
    426             comm = state._comm_manager.get_server_comm()
--> 427         model = self.get_root(doc, comm)
    428 
    429         if config.embed:

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/viewable.py in get_root(self, doc, comm, preprocess)
    482         """
    483         doc = init_doc(doc)
--> 484         root = self._get_model(doc, comm=comm)
    485         if preprocess:
    486             self._preprocess(root)

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/layout/base.py in _get_model(self, doc, root, parent, comm)
    111         if root is None:
    112             root = model
--> 113         objects = self._get_objects(model, [], doc, root, comm)
    114         props = dict(self._init_params(), objects=objects)
    115         model.update(**self._process_param_change(props))

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/layout/base.py in _get_objects(self, model, old_objects, doc, root, comm)
    101             else:
    102                 try:
--> 103                     child = pane._get_model(doc, root, model, comm)
    104                 except RerenderError:
    105                     return self._get_objects(model, current_objects[:i], doc, root, comm)

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/pane/holoviews.py in _get_model(self, doc, root, parent, comm)
    237             plot = self.object
    238         else:
--> 239             plot = self._render(doc, comm, root)
    240 
    241         plot.pane = self

/opt/conda/envs/cava-data/lib/python3.8/site-packages/panel/pane/holoviews.py in _render(self, doc, comm, root)
    302                 kwargs['comm'] = comm
    303 
--> 304         return renderer.get_plot(self.object, **kwargs)
    305 
    306     def _cleanup(self, root):

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/renderer.py in get_plot(self_or_cls, obj, doc, renderer, comm, **kwargs)
    241             init_key = tuple(v if d is None else d for v, d in
    242                              zip(plot.keys[0], defaults))
--> 243             plot.update(init_key)
    244         else:
    245             plot = obj

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plot.py in update(self, key)
    980     def update(self, key):
    981         if len(self) == 1 and ((key == 0) or (key == self.keys[0])) and not self.drawn:
--> 982             return self.initialize_plot()
    983         item = self.__getitem__(key)
    984         self.traverse(lambda x: setattr(x, '_updated', True))

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/element.py in initialize_plot(self, ranges, is_geo)
    124         """
    125         # Get element key and ranges for frame
--> 126         fig = self.generate_plot(self.keys[-1], ranges, is_geo=is_geo)
    127         self.drawn = True
    128 

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/element.py in generate_plot(self, key, ranges, element, is_geo)
    180         # Get data and options and merge them
    181         data = self.get_data(element, ranges, style, is_geo=is_geo)
--> 182         opts = self.graph_options(element, ranges, style, is_geo=is_geo)
    183 
    184         components = {

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/chart.py in graph_options(self, element, ranges, style, **kwargs)
     68 
     69     def graph_options(self, element, ranges, style, **kwargs):
---> 70         opts = super(ScatterPlot, self).graph_options(element, ranges, style, **kwargs)
     71         cdim = element.get_dimension(self.color_index)
     72         if cdim:

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/element.py in graph_options(self, element, ranges, style, is_geo, **kwargs)
    250 
    251         if self._style_key is not None:
--> 252             styles = self._apply_transforms(element, ranges, style)
    253 
    254             # If style starts with '{_style_key}_', remove the prefix.  This way

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/element.py in _apply_transforms(self, element, ranges, style)
    377             numeric = isinstance(val, np.ndarray) and val.dtype.kind in 'uifMm'
    378             if ('color' in k and isinstance(val, np.ndarray) and numeric):
--> 379                 copts = self.get_color_opts(v, element, ranges, style)
    380                 new_style.pop('cmap', None)
    381                 new_style.update(copts)

/opt/conda/envs/cava-data/lib/python3.8/site-packages/holoviews/plotting/plotly/element.py in get_color_opts(self, eldim, element, ranges, style)
    609                 title = eldim.pprint_label
    610 
--> 611             opts['colorbar'] = dict(title=title, **self.colorbar_opts)
    612             opts['showscale'] = True
    613         else:

TypeError: type object got multiple values for keyword argument 'title'
```

</details>

#### Screenshots or screencasts of the bug in action

![Screenshot from 2021-05-26 14-38-42](https://user-images.githubusercontent.com/17802172/119734604-17af3c80-be30-11eb-8407-e4315f47be6f.png)

Thank you so much for your time and help on this 😄 I hope it's a simple bug fix.



Discussion:
philippjfr: Looks good. Just one comment, there's little point adding user docstrings to plotting classes since no one directly uses them.
MarcSkovMadsen: Where should they be added then @philippjfr. I am a new contributor to HoloViews. Currently Scatter3D does not show any docstring.
philippjfr: The element is the right place. So this PR is totally fine, just doesn't have to be on the plotting class too.
MarcSkovMadsen: I don't understand. Could you be more specific?
philippjfr: Oh wait sorry, this PR is only on the plotting classes. It'll have to be in the holoviews/element directory.
MarcSkovMadsen: For me this is the element. But the docstring is not shown when I use `hv.help(hv.Scatter3D)`.

![image](https://user-images.githubusercontent.com/42288570/188262458-43490480-e50f-441b-af58-f3e5899dbecb.png)

codecov-commenter: # [Codecov](https://codecov.io/gh/holoviz/holoviews/pull/5418?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
> Merging [#5418](https://codecov.io/gh/holoviz/holoviews/pull/5418?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (e46773b) into [master](https://codecov.io/gh/holoviz/holoviews/commit/6c0dafb8dc0b2c059b0cfbf49908c51f243c309c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) (6c0dafb) will **increase** coverage by `0.02%`.
> The diff coverage is `n/a`.

> :exclamation: Current head e46773b differs from pull request most recent head 1f0bd42. Consider uploading reports for the commit 1f0bd42 to get more accurate results

```diff
@@            Coverage Diff             @@
##           master    #5418      +/-   ##
==========================================
+ Coverage   88.07%   88.10%   +0.02%     
==========================================
  Files         301      301              
  Lines       61991    61969      -22     
==========================================
- Hits        54596    54595       -1     
+ Misses       7395     7374      -21     
```


| [Impacted Files](https://codecov.io/gh/holoviz/holoviews/pull/5418?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Coverage Δ | |
|---|---|---|
| [holoviews/plotting/mpl/chart3d.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL21wbC9jaGFydDNkLnB5) | `57.55% <ø> (ø)` | |
| [holoviews/plotting/plotly/chart3d.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL3Bsb3RseS9jaGFydDNkLnB5) | `69.13% <ø> (ø)` | |
| [holoviews/tests/test\_streams.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL3Rlc3Rfc3RyZWFtcy5weQ==) | `98.24% <0.00%> (-0.11%)` | :arrow_down: |
| [holoviews/plotting/bokeh/element.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL2VsZW1lbnQucHk=) | `89.36% <0.00%> (+0.50%)` | :arrow_up: |
| [holoviews/tests/plotting/bokeh/test\_callbacks.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL3Bsb3R0aW5nL2Jva2VoL3Rlc3RfY2FsbGJhY2tzLnB5) | `100.00% <0.00%> (+1.37%)` | :arrow_up: |
| [holoviews/tests/core/data/test\_daskinterface.py](https://codecov.io/gh/holoviz/holoviews/pull/5418/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Rlc3RzL2NvcmUvZGF0YS90ZXN0X2Rhc2tpbnRlcmZhY2UucHk=) | `97.01% <0.00%> (+10.34%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz)

philippjfr: Okay, we'll have to take a look at hv.help then. Don't love the output in any case.
MarcSkovMadsen: I don't plan to do more here. I hope someone with the knowledge will see they can quickly finalize and it will be a big step forward for `Scatter3D`.
jlstevens: @philippjfr Looks close to being ready and I'm happy to merge once the tests are green. From the look of it, the unit tests need updating but I am confused as to why the expected strings are now dictionaries?
jbednar: @MarcSkovMadsen , will you be able to address those test failures (to do with the titles)? It would be great to get this merged.
MarcSkovMadsen: As stated in the comments I dont know how to fix this.
Hoxbro: I'm not sure I am following you, didn't my last commit finalize your tests? Or are you talking about another comment?
MarcSkovMadsen: Sorry and thanks. I was replying to @jbednar old comment.
Hoxbro: The failed mac os test on Python 3.11 yesterday was because a debug version of Python was being installed:
`python=3.11.0=h4150a38_1_debug_cpython`
https://github.com/holoviz/holoviews/actions/runs/3918675139/jobs/6699840165#step:2:4355

This should be fixed now. See https://github.com/conda-forge/python-feedstock/pull/597#issuecomment-1383060501

Patch:
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/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


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"