Repository: holoviz/holoviews
Pull request #6599 (2025-05-28 14:47:41)

enh(bokeh): Add `radius` and `radius_dimension` to PointPlot
Adds `radius` and `radius_dimension` to the Bokeh PointPlot. Because radius works with Circle, we update the `_plot_methods` before calling `_init_glyph`. 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/holoviz/holoviews/pull/6599?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) Report
Attention: Patch coverage is `75.00000%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 88.81%. Comparing base [(`311d7f9`)](https://app.codecov.io/gh/holoviz/holoviews/commit/311d7f9d4b6a06b9e9750ea81b474263aadb3f58?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) to head [(`fc61979`)](https://app.codecov.io/gh/holoviz/holoviews/commit/fc6197973ed3f3cd0467e15d70812c14eb1c6975?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

| [Files with missing lines](https://app.codecov.io/gh/holoviz/holoviews/pull/6599?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) | Patch % | Lines |
|---|---|---|
| [holoviews/plotting/bokeh/chart.py](https://app.codecov.io/gh/holoviz/holoviews/pull/6599?src=pr&el=tree&filepath=holoviews%2Fplotting%2Fbokeh%2Fchart.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz#diff-aG9sb3ZpZXdzL3Bsb3R0aW5nL2Jva2VoL2NoYXJ0LnB5) | 71.42% | [2 Missing :warning: ](https://app.codecov.io/gh/holoviz/holoviews/pull/6599?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6599      +/-   ##
==========================================
- Coverage   88.81%   88.81%   -0.01%     
==========================================
  Files         326      326              
  Lines       69530    69537       +7     
==========================================
+ Hits        61756    61759       +3     
- Misses       7774     7778       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/holoviz/holoviews/pull/6599?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=holoviz).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/.github/workflows/conventional_commits.yaml b/.github/workflows/conventional_commits.yaml
index bd04a4141..1d0de5590 100644
--- a/.github/workflows/conventional_commits.yaml
+++ b/.github/workflows/conventional_commits.yaml
@@ -42,3 +42,8 @@ jobs:
             plotly
           ignoreLabels: |
             ignore-semantic-pull-request
+          subjectPatternError: |
+            The subject "{subject}" found in the pull request title "{title}"
+            didn't match the configured pattern. Please ensure that the subject
+            doesn't start with an uppercase character. Also make sure that the
+            type ends with a colol, e.g. `docs:`
diff --git a/examples/reference/elements/bokeh/Points.ipynb b/examples/reference/elements/bokeh/Points.ipynb
index 48b85a959..ae7f041e6 100644
--- a/examples/reference/elements/bokeh/Points.ipynb
+++ b/examples/reference/elements/bokeh/Points.ipynb
@@ -155,6 +155,30 @@
     "\n",
     "For full documentation and the available style and plot options, use ``hv.help(hv.Points).``"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Scale by radius\n",
+    "If you want to scale the size of points based on a radius, this ensures that the visual scale remains consistent when you zoom in or out—since radius is specified in data coordinates, not screen pixels.\n",
+    "\n",
+    "Below is an example using `hv.Points`, where the radius is determined by normalizing the `zs` values:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x = np.arange(5)\n",
+    "y = np.arange(4)\n",
+    "z = np.arange(20).reshape(4, 5)\n",
+    "\n",
+    "radius_dim = dim(\"z\").norm() / 2\n",
+    "hv.Points((x, y, z), kdims=[\"x\", \"y\"], vdims=\"z\").opts(radius=radius_dim)"
+   ]
   }
  ],
  "metadata": {
diff --git a/examples/user_guide/Customizing_Plots.ipynb b/examples/user_guide/Customizing_Plots.ipynb
index da0293bf8..337af4d76 100644
--- a/examples/user_guide/Customizing_Plots.ipynb
+++ b/examples/user_guide/Customizing_Plots.ipynb
@@ -41,7 +41,7 @@
     "        - [Logarithmic axes](#log-axes)\n",
     "        - [Datetime axes](#datetime-axes)\n",
     "        - [Categorical axes](#categorical-axes)\n",
-    "    - [Axis position](#axis-position): Positioning and hiding axes\n",
+    "    - [Axis positions](#axis-positions): Positioning and hiding axes\n",
     "    - [Inverting axes](#inverting-axes): Flipping the x-/y-axes and inverting an axis\n",
     "    - [Axis labels](#axis-labels): Setting axis labels using dimensions and options\n",
     "    - [Axis ranges](#axis-ranges): Controlling axes ranges using dimensions, padding and options\n",
diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py
index 28c3e196d..46106ecb9 100644
--- a/holoviews/operation/datashader.py
+++ b/holoviews/operation/datashader.py
@@ -1779,8 +1779,11 @@ class SpreadingOperation(LinkableOperation):
                 for idx, k, in enumerate("RGBA"):
                     new_data[k].data = img[:, :, idx]
             elif isinstance(element, ImageStack):
-                for k in map(str, element.vdims):
-                    new_data[k].data = array.sel(z=k)
+                if len(element.vdims) == 1:
+                    new_data[element.vdims[0].name].data = array
+                else:
+                    for k in map(str, element.vdims):
+                        new_data[k].data = array.sel(z=k)
             elif isinstance(element, Image):
                 new_data[element.vdims[0].name].data = array
             else:
diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py
index b6fbfa100..f5c828a14 100644
--- a/holoviews/plotting/bokeh/__init__.py
+++ b/holoviews/plotting/bokeh/__init__.py
@@ -243,7 +243,7 @@ options.Curve = Options('style', color=Cycle(), line_width=2)
 options.BoxWhisker = Options('style', box_fill_color=Cycle(), whisker_color='black',
                              box_line_color='black', outlier_color='black')
 options.Scatter = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
-options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap)
+options.Points = Options('style', color=Cycle(), size=point_size, cmap=dflt_cmap, radius_dimension="min")
 options.Points = Options('plot', show_frame=True)
 options.Histogram = Options('style', line_color='black', color=Cycle(), muted_alpha=0.2)
 options.ErrorBars = Options('style', color='black')
diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py
index 870540dee..0f794d6c2 100644
--- a/holoviews/plotting/bokeh/chart.py
+++ b/holoviews/plotting/bokeh/chart.py
@@ -63,12 +63,21 @@ class PointPlot(LegendPlot, ColorbarPlot):
 
     style_opts = [
         "cmap", "palette", "marker", "size", "angle", "hit_dilation",
+        "radius", "radius_dimension",
         *base_properties, *line_properties, *fill_properties
     ]
 
     _plot_methods = dict(single='scatter', batched='scatter')
     _batched_style_opts = line_properties + fill_properties + ['size', 'marker', 'angle']
 
+    def _init_glyph(self, plot, mapping, properties):
+        if "radius" in properties:
+            self._plot_methods = dict(single='circle', batched='circle')
+            properties.pop("size", None)
+        else:
+            properties.pop("radius_dimension", None)
+        return super()._init_glyph(plot, mapping, properties)
+
     def _get_size_data(self, element, ranges, style):
         data, mapping = {}, {}
         sdim = element.get_dimension(self.size_index)


Test patch:
diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py
index e3425b880..cb609142c 100644
--- a/holoviews/tests/operation/test_datashader.py
+++ b/holoviews/tests/operation/test_datashader.py
@@ -1468,6 +1468,16 @@ def test_selector_datashade_bad_column_name(point_data):
         datashade(point_plot, selector=ds.min("val"), **inputs)
 
 
+@pytest.mark.usefixtures("bokeh_backend")
+def test_selector_single_categorical():
+    # Test for https://github.com/holoviz/holoviews/issues/6595
+    plot = Points(([0, 1], [0, 1], ["A", "A"]), ["X", "Y"], "C")
+    plot = rasterize(plot, aggregator=ds.count_cat("C"), selector=ds.first("X"))
+    plot = dynspread(plot)
+    # Should not fail
+    renderer("bokeh").get_plot(plot)
+
+
 class DatashaderSpreadTests(ComparisonTestCase):
 
     def test_spread_rgb_1px(self):
diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py
index aaced1d36..21a5c8442 100644
--- a/holoviews/tests/plotting/bokeh/test_pointplot.py
+++ b/holoviews/tests/plotting/bokeh/test_pointplot.py
@@ -2,8 +2,15 @@ import datetime as dt
 
 import numpy as np
 import pandas as pd
-from bokeh.models import CategoricalColorMapper, FactorRange, LinearColorMapper, Scatter
-
+from bokeh.models import (
+    CategoricalColorMapper,
+    Circle,
+    FactorRange,
+    LinearColorMapper,
+    Scatter,
+)
+
+import holoviews as hv
 from holoviews.core import NdOverlay
 from holoviews.core.options import Cycle
 from holoviews.element import Points
@@ -540,3 +547,19 @@ class TestPointPlot(TestBokehPlot):
             "size_index; ignoring the size_index.\n"
         )
         self.assertEqual(log_msg, warning)
+
+    def test_point_radius(self):
+        x, y = 4, 5
+        xs = np.arange(x)
+        ys = np.arange(y)
+        zs = np.arange(x * y).reshape(y, x)
+        plot = Points((xs, ys, zs,), kdims=["xs", "ys"], vdims="zs")
+        plot.opts(radius=hv.dim("zs").norm() / 2)
+
+        handles = bokeh_renderer.get_plot(plot).handles
+        glyph = handles["glyph"]
+        assert isinstance(glyph, Circle)
+        assert glyph.radius_dimension == "min"
+
+        norm = zs.T.ravel() / np.max(zs) / 2
+        np.testing.assert_array_equal(handles["cds"].data["radius"], norm)