From 83fab9274027f86c773ac42c86da95c2a26853b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 13 Aug 2025 11:41:04 +0200 Subject: [PATCH 1/8] feat: Add sizebar support to PointPlot --- holoviews/plotting/bokeh/chart.py | 76 +++++++++++++++++++++++++++-- holoviews/plotting/bokeh/element.py | 2 +- holoviews/plotting/bokeh/util.py | 1 + 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index bbd5b72e65..158303c07d 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -3,7 +3,7 @@ import numpy as np import param -from bokeh.models import CategoricalColorMapper, CustomJS, Whisker +from bokeh.models import CategoricalColorMapper, CustomJS, Scatter, Whisker from bokeh.models.tools import BoxSelectTool from bokeh.transform import jitter @@ -12,6 +12,7 @@ from ...core.util import dimension_sanitizer, isdatetime, isfinite from ...operation import interpolate_curve from ...util.transform import dim +from ...util.warnings import warn from ..mixins import AreaMixin, BarsMixin, SpikesMixin from ..util import compute_sizes, get_min_distance from .element import ColorbarPlot, ElementPlot, LegendPlot, OverlayPlot @@ -24,10 +25,79 @@ mpl_to_bokeh, rgb2hex, ) -from .util import categorize_array +from .util import BOKEH_GE_3_8_0, categorize_array -class PointPlot(LegendPlot, ColorbarPlot): +class SizeBarMixin(LegendPlot): + + sizebar = param.Boolean(default=False, doc=""" + Whether to display a sizebar.""") + + sizebar_location = param.Selector( + default="below", + objects=["above", "below", "left", "right", "center"], + doc=""" + Location anchor for positioning scale bar, default to 'below. + + The sizebar_location is only used if sizebar is True.""") + + sizebar_orientation = param.Selector(default="horizontal", objects=["horizontal", "vertical"], doc=""" + Orientation of the sizebar, default to 'horizontal'. + + The sizebar_orientation is only used if sizebar is True. + """) + + sizebar_color = param.String(default="black", doc=""" + Color of the glyph in the sizebar, default to 'black'. + + The sizebar_color is only used if sizebar is True.""") + + sizebar_alpha = param.Number(default=0.6, bounds=(0, 1), doc=""" + Alpha value of the glyph in the sizebar, default to 0.6. + + The sizebar_alpha is only used if sizebar is True.""") + + sizebar_opts = param.Dict( + default={}, doc=""" + Allows setting specific styling options for the sizebar. + See https://docs.bokeh.org/en/latest/docs/reference/models/annotations.html#bokeh.models.SizeBar + for more information. + + The sizebar_opts is only used if sizebar is True.""") + + def _init_glyph(self, plot, mapping, properties): + renderer, glyph = super()._init_glyph(plot, mapping, properties) + if self.sizebar: + self._draw_sizebar(plot, renderer, glyph) + return renderer, glyph + + def _draw_sizebar(self, plot, renderer, glyph): + if not BOKEH_GE_3_8_0: + raise RuntimeError("Sizebar requires Bokeh >= 3.8.0") + + from bokeh.models import SizeBar + from bokeh.models.glyph import RadialGlyph + + if not isinstance(glyph, RadialGlyph): + if isinstance(glyph, Scatter): + # PointPlot have both Scatter and Circle plot methods + msg = "For sizebar to work you need to have radius set" + warn(msg, category=RuntimeWarning) + return + + sizebar_kwargs = dict( + self.sizebar_opts, + renderer=renderer, + orientation=self.sizebar_orientation, + glyph_fill_color=self.sizebar_color, + glyph_fill_alpha=self.sizebar_alpha, + ) + sizebar = SizeBar(**sizebar_kwargs) + plot.add_layout(sizebar, self.sizebar_location) + self.handles['sizebar'] = sizebar + + +class PointPlot(SizeBarMixin, ColorbarPlot): jitter = param.Number(default=None, bounds=(0, None), doc=""" The amount of jitter to apply to offset the points along the x-axis.""") diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index baae2d6410..3bb682c12e 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -197,7 +197,7 @@ class ElementPlot(BokehPlot, GenericElementPlot): scalebar = param.Boolean(default=False, doc=""" Whether to display a scalebar.""") - scalebar_range =param.Selector(default="x", objects=["x", "y"], doc=""" + scalebar_range = param.Selector(default="x", objects=["x", "y"], doc=""" Whether to have the scalebar on the x or y axis.""") scalebar_unit = param.ClassSelector(default=None, class_=(str, tuple), doc=""" diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index bc8c448ad0..71a8a43536 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -68,6 +68,7 @@ BOKEH_GE_3_5_0 = BOKEH_VERSION >= (3, 5, 0) BOKEH_GE_3_6_0 = BOKEH_VERSION >= (3, 6, 0) BOKEH_GE_3_7_0 = BOKEH_VERSION >= (3, 7, 0) +BOKEH_GE_3_8_0 = BOKEH_VERSION >= (3, 8, 0) TOOL_TYPES = { 'pan': tools.PanTool, From 2f0e6f06dc76b6742ffecc16cfbb8df917c6fb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 13 Aug 2025 12:49:23 +0200 Subject: [PATCH 2/8] Use PointPlot to check for scatter --- holoviews/plotting/bokeh/chart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 158303c07d..2ed6fc0250 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -3,7 +3,7 @@ import numpy as np import param -from bokeh.models import CategoricalColorMapper, CustomJS, Scatter, Whisker +from bokeh.models import CategoricalColorMapper, CustomJS, Whisker from bokeh.models.tools import BoxSelectTool from bokeh.transform import jitter @@ -79,7 +79,7 @@ def _draw_sizebar(self, plot, renderer, glyph): from bokeh.models.glyph import RadialGlyph if not isinstance(glyph, RadialGlyph): - if isinstance(glyph, Scatter): + if isinstance(self, PointPlot): # PointPlot have both Scatter and Circle plot methods msg = "For sizebar to work you need to have radius set" warn(msg, category=RuntimeWarning) From 53e440bb32498bc9b339d381ddb8a950f3c87325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 18 Aug 2025 14:01:48 +0200 Subject: [PATCH 3/8] Add default settings after the last work --- holoviews/plotting/bokeh/chart.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 2ed6fc0250..34b41946bb 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -57,6 +57,12 @@ class SizeBarMixin(LegendPlot): The sizebar_alpha is only used if sizebar is True.""") + sizebar_bounds = param.NumericTuple(default=None, length=2, doc=""" + Bounds of the sizebar, default to None which will automatically + determine the bounds based on the data. + + The sizebar_bounds is only used if sizebar is True.""") + sizebar_opts = param.Dict( default={}, doc=""" Allows setting specific styling options for the sizebar. @@ -91,7 +97,16 @@ def _draw_sizebar(self, plot, renderer, glyph): orientation=self.sizebar_orientation, glyph_fill_color=self.sizebar_color, glyph_fill_alpha=self.sizebar_alpha, + bounds=self.sizebar_bounds or "auto", ) + + if "width" not in sizebar_kwargs: # Width is the primary axis + match (self.sizebar_location, self.sizebar_orientation): + case ("above" | "below", "horizontal"): + sizebar_kwargs['width'] = "max" + case ("left" | "right", "vertical"): + sizebar_kwargs['width'] = "max" + sizebar = SizeBar(**sizebar_kwargs) plot.add_layout(sizebar, self.sizebar_location) self.handles['sizebar'] = sizebar From f176894745e0efbb5cc0ad653cbceeb2e085d9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 18 Aug 2025 14:24:21 +0200 Subject: [PATCH 4/8] Init sizebar test cases --- .../tests/plotting/bokeh/test_pointplot.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py index a94fe6ee67..9eb1b8ae8b 100644 --- a/holoviews/tests/plotting/bokeh/test_pointplot.py +++ b/holoviews/tests/plotting/bokeh/test_pointplot.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +import pytest from bokeh.models import ( CategoricalColorMapper, Circle, @@ -14,7 +15,7 @@ from holoviews.core import NdOverlay from holoviews.core.options import Cycle from holoviews.element import Points -from holoviews.plotting.bokeh.util import property_to_dict +from holoviews.plotting.bokeh.util import BOKEH_GE_3_8_0, property_to_dict from holoviews.streams import Stream from ..utils import ParamLogStream @@ -581,3 +582,30 @@ def test_point_radius_then_size_then_radius(self): handles = bokeh_renderer.get_plot(plot).handles glyph = handles["glyph"] assert isinstance(glyph, Circle) + + +@pytest.mark.skipif(not BOKEH_GE_3_8_0, reason="Needs Bokeh 3.8") +class TestSizeBar(TestBokehPlot): + + def setUp(self): + super().setUp() + + np.random.seed(1) + N = 100 + x = np.random.random(size=N) * 100 + y = np.random.random(size=N) * 100 + radii = np.random.random(size=N) * 10 + self.plot = hv.Points((x, y, radii), vdims=["radii"]).opts(radius="radii") + + def get_sizebar(self): + return bokeh_renderer.get_plot(self.plot).handles.get("sizebar") + + def test_sizebar_init(self): + from bokeh.models import SizeBar + + sizebar = self.get_sizebar() + assert sizebar is None + + self.plot.opts(sizebar=True) + sizebar = self.get_sizebar() + assert isinstance(sizebar, SizeBar) From b72639a2929897bdbbed1faa6d59cdf730735bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 18 Aug 2025 15:28:04 +0200 Subject: [PATCH 5/8] Add more test --- holoviews/plotting/bokeh/chart.py | 10 ++-- .../tests/plotting/bokeh/test_pointplot.py | 60 ++++++++++++++++--- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 34b41946bb..ecb3237b5a 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -28,7 +28,7 @@ from .util import BOKEH_GE_3_8_0, categorize_array -class SizeBarMixin(LegendPlot): +class SizebarMixin(LegendPlot): sizebar = param.Boolean(default=False, doc=""" Whether to display a sizebar.""") @@ -37,7 +37,7 @@ class SizeBarMixin(LegendPlot): default="below", objects=["above", "below", "left", "right", "center"], doc=""" - Location anchor for positioning scale bar, default to 'below. + Location anchor for positioning scale bar, default to 'below'. The sizebar_location is only used if sizebar is True.""") @@ -103,16 +103,16 @@ def _draw_sizebar(self, plot, renderer, glyph): if "width" not in sizebar_kwargs: # Width is the primary axis match (self.sizebar_location, self.sizebar_orientation): case ("above" | "below", "horizontal"): - sizebar_kwargs['width'] = "max" + sizebar_kwargs["width"] = "max" case ("left" | "right", "vertical"): - sizebar_kwargs['width'] = "max" + sizebar_kwargs["width"] = "max" sizebar = SizeBar(**sizebar_kwargs) plot.add_layout(sizebar, self.sizebar_location) self.handles['sizebar'] = sizebar -class PointPlot(SizeBarMixin, ColorbarPlot): +class PointPlot(SizebarMixin, ColorbarPlot): jitter = param.Number(default=None, bounds=(0, None), doc=""" The amount of jitter to apply to offset the points along the x-axis.""") diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py index 9eb1b8ae8b..b3f28a0a19 100644 --- a/holoviews/tests/plotting/bokeh/test_pointplot.py +++ b/holoviews/tests/plotting/bokeh/test_pointplot.py @@ -15,6 +15,7 @@ from holoviews.core import NdOverlay from holoviews.core.options import Cycle from holoviews.element import Points +from holoviews.plotting.bokeh.chart import SizebarMixin from holoviews.plotting.bokeh.util import BOKEH_GE_3_8_0, property_to_dict from holoviews.streams import Stream @@ -585,11 +586,9 @@ def test_point_radius_then_size_then_radius(self): @pytest.mark.skipif(not BOKEH_GE_3_8_0, reason="Needs Bokeh 3.8") -class TestSizeBar(TestBokehPlot): - - def setUp(self): - super().setUp() +class TestSizeBar: + def setup_method(self): np.random.seed(1) N = 100 x = np.random.random(size=N) * 100 @@ -597,15 +596,58 @@ def setUp(self): radii = np.random.random(size=N) * 10 self.plot = hv.Points((x, y, radii), vdims=["radii"]).opts(radius="radii") + def get_handles(self): + return bokeh_renderer.get_plot(self.plot).handles + def get_sizebar(self): - return bokeh_renderer.get_plot(self.plot).handles.get("sizebar") + return self.get_handles().get("sizebar") - def test_sizebar_init(self): + def test_init(self): from bokeh.models import SizeBar - sizebar = self.get_sizebar() - assert sizebar is None + assert self.get_sizebar() is None self.plot.opts(sizebar=True) + assert isinstance(self.get_sizebar(), SizeBar) + + @pytest.mark.parametrize("location", [SizebarMixin.param.sizebar_location.default]) + def test_location(self, location): + self.plot.opts(sizebar=True, sizebar_location=location) + handles = self.get_handles() + assert handles["sizebar"] in getattr(handles["plot"], location) + + @pytest.mark.parametrize("orientation", [SizebarMixin.param.sizebar_orientation.default]) + def test_orientation(self, orientation): + self.plot.opts(sizebar=True, sizebar_orientation=orientation) + assert self.get_sizebar().orientation == orientation + + def test_style(self): + self.plot.opts(sizebar=True, sizebar_color="red", sizebar_alpha = 0.1) sizebar = self.get_sizebar() - assert isinstance(sizebar, SizeBar) + assert sizebar.glyph_fill_alpha == 0.1 + assert sizebar.glyph_fill_color == "red" + + @pytest.mark.parametrize("bounds", [(0, 10), (0, float("inf"))]) + def test_bounds(self, bounds): + self.plot.opts(sizebar=True, sizebar_bounds=bounds) + assert self.get_sizebar().bounds == bounds + + @pytest.mark.parametrize("location", [SizebarMixin.param.sizebar_location.default]) + @pytest.mark.parametrize("orientation", [SizebarMixin.param.sizebar_orientation.default]) + @pytest.mark.parametrize("set_width", [True, False]) + def test_max_size(self, location, orientation, set_width): + self.plot.opts(sizebar=True, sizebar_location=location, sizebar_orientation=orientation) + if set_width: + self.plot.opts(sizebar_opts={"width": 216}) # Using 216 as it will never be a default + + width = self.get_sizebar().width + match (location, orientation, set_width): + case ("above" | "below", "horizontal", False): + assert width == "max" + case ("left" | "right", "vertical", False): + assert width == "max" + case _: + if set_width: + assert width == 216 + else: + assert width != "max" From 05cc2d6c4d56e10ed59688781eed260d3679100c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 18 Aug 2025 15:41:07 +0200 Subject: [PATCH 6/8] Add simple test for overlay and layout --- .../tests/plotting/bokeh/test_pointplot.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/holoviews/tests/plotting/bokeh/test_pointplot.py b/holoviews/tests/plotting/bokeh/test_pointplot.py index b3f28a0a19..b165d60f65 100644 --- a/holoviews/tests/plotting/bokeh/test_pointplot.py +++ b/holoviews/tests/plotting/bokeh/test_pointplot.py @@ -651,3 +651,24 @@ def test_max_size(self, location, orientation, set_width): assert width == 216 else: assert width != "max" + + def test_overlay(self): + # Mainly just to check it does not raise an exception + p1 = self.plot.opts(sizebar=True) + p2 = hv.Curve([1, 2, 3]) + combined = p1 * p2 + + bk_element = hv.render(combined) + assert len(bk_element.renderers) == 2 # the two plots + assert len(bk_element.below) == 2 # axis and sizebar + + def test_layout(self): + # Mainly just to check it does not raise an exception + p1 = self.plot.opts(sizebar=True) + p2 = hv.Curve([1, 2, 3]) + combined = p1 + p2 + + bk_element = hv.render(combined) + assert len(bk_element.children) == 2 + assert len(bk_element.children[0][0].below) == 2 + assert len(bk_element.children[1][0].below) == 1 From fe8d3f43e537689ab054f5bd6979d63ea42e31c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 19 Aug 2025 15:20:09 +0200 Subject: [PATCH 7/8] Add documentation --- .../reference/features/bokeh/Sizebar.ipynb | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 examples/reference/features/bokeh/Sizebar.ipynb diff --git a/examples/reference/features/bokeh/Sizebar.ipynb b/examples/reference/features/bokeh/Sizebar.ipynb new file mode 100644 index 0000000000..2b780f746d --- /dev/null +++ b/examples/reference/features/bokeh/Sizebar.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "df229fb9-0a56-47b4-a8d2-72cab8782624", + "metadata": {}, + "source": [ + "#### **Title**: Sizebar\n", + "\n", + "**Dependencies**: Bokeh >= 3.8.0\n", + "\n", + "**Backends**: [Bokeh](./Sizebar.ipynb)\n", + "\n", + "The `sizebar` feature makes a sizebar on the element to help gauge the size of circles on a plot. For this to work it need to have `radius` option set." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1cee420f-3ad1-4b0c-ae66-b26d726d7a0a", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "import holoviews as hv\n", + "from holoviews.plotting.util import rgb2hex\n", + "\n", + "rng = np.random.default_rng(1)\n", + "N = 100\n", + "x = rng.random(size=N) * 100\n", + "y = rng.random(size=N) * 100\n", + "radii = rng.random(size=N) * 10\n", + "colors = np.array(\n", + " [(r, g, 150) for r, g in zip(50 + 2 * x, 30 + 2 * y)], dtype=np.uint8\n", + ")\n", + "colors = list(map(rgb2hex, colors / 256))\n", + "\n", + "hv.extension(\"bokeh\")\n", + "\n", + "points = hv.Points((x, y, radii, colors), vdims=[\"radii\", \"colors\"])\n", + "points.opts(\n", + " color=\"colors\", radius=\"radii\", alpha=0.6, height=500, width=500, sizebar=True\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d58cb23b-206b-4368-aed7-b083a7766320", + "metadata": {}, + "source": [ + "Zoom in and out to see the sizebar dynamically adjust." + ] + }, + { + "cell_type": "markdown", + "id": "39af797f-4ea2-449b-96c7-eb7d0da8394e", + "metadata": {}, + "source": [ + "### Customization\n", + "\n", + "In the plot above, you can see that we applied the sizebar. Below are further customization options for the sizebar:\n", + "\n", + "- The `sizebar_location` parameter defines the positioning anchor for the sizebar. Options include `\"above\"`, `\"below\"`, `\"left\"`, `\"right\"`, and `\"center\"`.\n", + "- The `sizebar_orientation` parameter controls whether the sizebar is displayed `\"horizontal\"` (default) or `\"vertical\"`.\n", + "- The `sizebar_color` parameter specifies the glyph color in the sizebar (default: `\"black\"`).\n", + "- The `sizebar_alpha` parameter sets the transparency of the sizebar glyph, between `0` and `1` (default: `0.6`).\n", + "- The `sizebar_bounds` parameter can be used to manually set the bounds of the sizebar as a tuple `(min, max)`. By default, `None` lets the bounds be determined automatically from the data.\n", + "- The `sizebar_opts` parameter enables specific styling options for the sizebar. See the [Bokeh SizeBar documentation](https://docs.bokeh.org/en/latest/docs/reference/models/annotations.html#bokeh.models.SizeBar) for available options.\n", + "\n", + "All these parameters are only utilized if `sizebar=True` is set in `.opts()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ab063b3-d1cb-40b4-9fa7-a5860932c16d", + "metadata": {}, + "outputs": [], + "source": [ + "points.clone().opts(\n", + " sizebar_location=\"right\",\n", + " sizebar_orientation=\"vertical\",\n", + " sizebar_color=\"red\",\n", + " sizebar_alpha=1,\n", + ")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From b0047af82afa36186c157f9fdd751b83bdface8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 20 Aug 2025 09:05:18 +0200 Subject: [PATCH 8/8] Join match case --- holoviews/plotting/bokeh/chart.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index ecb3237b5a..5c485864bd 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -102,9 +102,7 @@ def _draw_sizebar(self, plot, renderer, glyph): if "width" not in sizebar_kwargs: # Width is the primary axis match (self.sizebar_location, self.sizebar_orientation): - case ("above" | "below", "horizontal"): - sizebar_kwargs["width"] = "max" - case ("left" | "right", "vertical"): + case (("above" | "below"), "horizontal") | (("left" | "right"), "vertical"): sizebar_kwargs["width"] = "max" sizebar = SizeBar(**sizebar_kwargs)