diff --git a/doc/conf.py b/doc/conf.py
index 1c2cb85a52..547d83e0a5 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -91,7 +91,8 @@
'elements',
'containers',
'streams',
- 'apps'
+ 'apps',
+ 'features',
]
}
diff --git a/examples/reference/features/assets/pollen.png b/examples/reference/features/assets/pollen.png
new file mode 100644
index 0000000000..4ffc548318
Binary files /dev/null and b/examples/reference/features/assets/pollen.png differ
diff --git a/examples/reference/features/bokeh/Scalebar.ipynb b/examples/reference/features/bokeh/Scalebar.ipynb
new file mode 100644
index 0000000000..ddb8b09e2a
--- /dev/null
+++ b/examples/reference/features/bokeh/Scalebar.ipynb
@@ -0,0 +1,144 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "df229fb9-0a56-47b4-a8d2-72cab8782624",
+ "metadata": {},
+ "source": [
+ "#### **Title**: Scalebar\n",
+ "\n",
+ "**Dependencies**: Bokeh\n",
+ "\n",
+ "**Backends**: [Bokeh](./Scalebar.ipynb)\n",
+ "\n",
+ "The `scalebar` feature overlays a scale bar on the element to help gauge the size of features on a plot. This is particularly useful for maps, images like CT or MRI scans, and other scenarios where traditional axes might be insufficient."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1cee420f-3ad1-4b0c-ae66-b26d726d7a0a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import holoviews as hv\n",
+ "import numpy as np\n",
+ "\n",
+ "hv.extension(\"bokeh\")\n",
+ "\n",
+ "pollen = hv.RGB.load_image(\"../assets/pollen.png\", bounds=(-10, -5, 10, 15)).opts(scalebar=True)\n",
+ "pollen"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d58cb23b-206b-4368-aed7-b083a7766320",
+ "metadata": {},
+ "source": [
+ "Zoom in and out to see the scale bar dynamically adjust."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "04f983fc-69cf-4b79-bdd2-6e437366167d",
+ "metadata": {},
+ "source": [
+ "### Custom Units"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "298933cc-8122-4252-b510-fca24f07326b",
+ "metadata": {},
+ "source": [
+ "By default, the `scalebar` uses meters. To customize the units, use the `scalebar_unit` parameter, which accepts a tuple of two strings: the first for the actual measurement and the second for the base unit that remains invariant regardless of scale. In the example below, the y-axis unit is micro-volts (`µV`), and the base unit is Volts (`V`).\n",
+ "\n",
+ "You can also apply a unit to the y-label independently of the scale bar specification using `hv.Dimension`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1140d0c9-5538-477c-b461-82f09648bd1c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "dim = hv.Dimension('Voltage', unit='µV')\n",
+ "hv.Curve(np.random.rand(1000), ['time'], [dim]).opts(\n",
+ " width=400,\n",
+ " scalebar=True,\n",
+ " scalebar_range='y',\n",
+ " scalebar_unit=('µV', 'V'),\n",
+ ")"
+ ]
+ },
+ {
+ "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 scalebar to the y-axis by specifying the `scalebar_range` argument. Below are further customization options for the scalebar:\n",
+ "\n",
+ "- The `scalebar_location` parameter defines the positioning anchor for the scalebar, with options like \"bottom_right\", \"top_left\", \"center\", etc.\n",
+ "- The `scalebar_label` parameter allows customization of the label template, using variables such as `@{value}` and `@{unit}`.\n",
+ "- The `scalebar_opts` parameter enables specific styling options for the scalebar, as detailed in the [Bokeh's documentation](https://docs.bokeh.org/en/latest/docs/reference/models/annotations.html#bokeh.models.ScaleBar).\n",
+ "\n",
+ "All these parameters are only utilized if `scalebar` is set to `True` in `.opts()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0ab063b3-d1cb-40b4-9fa7-a5860932c16d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "dim = hv.Dimension('Voltage', unit='µV')\n",
+ "hv.Curve(np.random.rand(1000), ['time'], [dim]).opts(\n",
+ " color='lightgrey',\n",
+ " width=400,\n",
+ " scalebar=True,\n",
+ " scalebar_range='y',\n",
+ " scalebar_unit=('µV', 'V'),\n",
+ " scalebar_location = 'top_right',\n",
+ " scalebar_label = '@{value} [@{unit}]',\n",
+ " scalebar_opts={\n",
+ " 'background_fill_alpha': 0,\n",
+ " 'border_line_color': None,\n",
+ " 'label_text_font_size': '20px',\n",
+ " 'label_text_color': 'maroon',\n",
+ " 'label_text_alpha': .5,\n",
+ " 'label_location': 'left',\n",
+ " 'length_sizing': 'exact',\n",
+ " 'bar_length': 0.5,\n",
+ " 'bar_line_color': 'maroon',\n",
+ " 'bar_line_alpha': .5, \n",
+ " 'bar_line_width': 5,\n",
+ " 'margin': 0,\n",
+ " 'padding': 5,\n",
+ " },\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "871c7b55-353e-4ec6-8b79-bf1299cd2a45",
+ "metadata": {},
+ "source": [
+ "### Toolbar \n",
+ "\n",
+ "The scalebar tool is added to the toolbar with a measurement ruler icon. Toggling this icon will either hide or show the scalebars. To remove scalebar icon from the toolbar, set `scalebar_tool = False`.\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "language_info": {
+ "name": "python",
+ "pygments_lexer": "ipython3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/reference/features/bokeh/table_hooks_example.ipynb b/examples/reference/features/bokeh/table_hooks_example.ipynb
deleted file mode 100644
index d925f114ba..0000000000
--- a/examples/reference/features/bokeh/table_hooks_example.ipynb
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "import holoviews as hv\n",
- "from holoviews import opts\n",
- "from bokeh.models import HTMLTemplateFormatter\n",
- "hv.extension('bokeh')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Declare Data"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "name = ['homepage', 'github', 'chat']\n",
- "link = ['http://holoviews.org', 'https://github.com/holoviz/holoviews', 'https://gitter.im/pyviz/pyviz']\n",
- "table = hv.Table({'Name':name, 'Link':link}, kdims=[], vdims=['Name', 'Link'])"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Plot"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "opts.defaults(opts.Table(width=500))\n",
- "def apply_format(plot, element):\n",
- " plot.handles['plot'].columns[1].formatter=HTMLTemplateFormatter(template='\"><%= value %>')\n",
- "\n",
- "table.options(hooks=[apply_format])"
- ]
- }
- ],
- "metadata": {
- "language_info": {
- "name": "python",
- "pygments_lexer": "ipython3"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index e46be71504..0c5b458489 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -1,6 +1,8 @@
+import base64
import warnings
from collections import defaultdict
from itertools import chain
+from textwrap import dedent
from types import FunctionType
import bokeh
@@ -13,6 +15,7 @@
BinnedTicker,
ColorBar,
ColorMapper,
+ CustomAction,
CustomJS,
EqHistColorMapper,
GlyphRenderer,
@@ -43,7 +46,6 @@
Ticker,
)
from bokeh.models.tools import Tool
-from packaging.version import Version
from ...core import CompositeOverlay, Dataset, Dimension, DynamicMap, Element, util
from ...core.options import Keywords, SkipRendering, abbreviated_exception
@@ -67,7 +69,7 @@
from .util import (
TOOL_TYPES,
bokeh32,
- bokeh_version,
+ bokeh34,
cds_column_replace,
compute_layout_properties,
date_to_integer,
@@ -183,6 +185,62 @@ class ElementPlot(BokehPlot, GenericElementPlot):
elements and the overlay container, allowing customization on a
per-axis basis.""")
+ scalebar = param.Boolean(default=False, doc="""
+ Whether to display a scalebar.""")
+
+ 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="""
+ Unit of the scalebar. The order of how this will be done is by:
+
+ 1. This value if it is set.
+ 2. The elements kdim unit (if exist).
+ 3. Meter
+
+ If the value is a tuple, the first value will be the unit and the
+ second will be the base unit.
+
+ The scalebar_unit is only used if scalebar is True.""")
+
+ scalebar_location = param.Selector(
+ default="bottom_right",
+ objects=[
+ "top_left", "top_center", "top_right",
+ "center_left", "center_center", "center_right",
+ "bottom_left", "bottom_center", "bottom_right",
+ "top", "left", "center", "right","bottom"
+ ],
+ doc="""
+ Location anchor for positioning scale bar.
+
+ The scalebar_location is only used if scalebar is True.""")
+
+ scalebar_label = param.String(
+ default="@{value} @{unit}", doc="""
+ The label template.
+
+ This can use special variables:
+ * ``@{value}`` The current value. Optionally can provide a number
+ formatter with e.g. ``@{value}{%.2f}``.
+ * ``@{unit}`` The unit of measure.
+
+ The scalebar_label is only used if scalebar is True.""")
+
+ scalebar_tool = param.Boolean(default=True, doc="""
+ Whether to show scalebar tools in the toolbar,
+ the tools are used to control scalebars visibility.
+
+ The scalebar_tool is only used if scalebar is True.""")
+
+ scalebar_opts = param.Dict(
+ default={}, doc="""
+ Allows setting specific styling options for the scalebar.
+ See https://docs.bokeh.org/en/latest/docs/reference/models/annotations.html#bokeh.models.ScaleBar
+ for more information.
+
+ The scalebar_opts is only used if scalebar is True.""")
+
subcoordinate_y = param.ClassSelector(default=False, class_=(bool, tuple), doc="""
Enables sub-coordinate systems for this plot. Accepts also a numerical
two-tuple that must be a range between 0 and 1, the plot will be
@@ -1991,6 +2049,9 @@ def _init_glyphs(self, plot, element, ranges, source):
self._postprocess_hover(renderer, source)
+ if self.scalebar:
+ self._draw_scalebar(plot)
+
zooms_subcoordy = self.handles.get('zooms_subcoordy')
if zooms_subcoordy is not None:
for zoom in zooms_subcoordy.values():
@@ -2274,6 +2335,78 @@ def framewise(self):
return any(self.lookup_options(frame, 'norm').options.get('framewise')
for frame in current_frames)
+ def _draw_scalebar(self, plot):
+ """Draw scalebar on the plot
+
+ This will draw a scalebar on the plot. See the documentation for
+ the parameters: `scalebar`, `scalebar_location`, `scalebar_label`,
+ `scalebar_opts`, and `scalebar_unit` for more information.
+
+ Requires Bokeh 3.4
+ """
+
+ if not bokeh34:
+ raise RuntimeError("Scalebar requires Bokeh >= 3.4.0")
+
+ from bokeh.models import Metric, ScaleBar
+
+ kdims = self.current_frame.kdims
+ unit = self.scalebar_unit or kdims[0].unit or "m"
+ if isinstance(unit, tuple):
+ unit, base_unit = unit[:2]
+ else:
+ base_unit = unit
+
+ _default_scalebar_opts = {"background_fill_alpha": 0.8}
+ opts = dict(_default_scalebar_opts, **self.scalebar_opts)
+
+ scale_bar = ScaleBar(
+ range=plot.x_range if self.scalebar_range == "x" else plot.y_range,
+ orientation="horizontal" if self.scalebar_range == "x" else "vertical",
+ unit=unit,
+ dimensional=Metric(base_unit=base_unit),
+ location=self.scalebar_location,
+ label=self.scalebar_label,
+ **opts,
+ )
+ self.handles['scalebar'] = scale_bar
+ plot.add_layout(scale_bar)
+
+ if plot.toolbar and self.scalebar_tool:
+ existing_tool = [t for t in plot.toolbar.tools if t.description == "Toggle ScaleBar"]
+ if existing_tool:
+ existing_tool[0].callback.args["scale_bars"] = plot.select(ScaleBar)
+ else:
+ ruler_icon = """\
+
+ """
+ encoded_icon = base64.b64encode(dedent(ruler_icon).encode()).decode('ascii')
+ scalebar_tool = CustomAction(
+ icon=f"data:image/svg+xml;base64,{encoded_icon}",
+ description="Toggle ScaleBar",
+ callback=CustomJS(
+ args={"scale_bars": plot.select(ScaleBar)},
+ code="""
+ export default ({scale_bars}) => {
+ for (let i = 0; i < scale_bars.length; i++) {
+ scale_bars[i].visible = !scale_bars[i].visible
+ }
+ }""",
+ )
+ )
+ plot.toolbar.tools.append(scalebar_tool)
+
class CompositeElementPlot(ElementPlot):
"""
@@ -2700,8 +2833,7 @@ def _get_cmapper_opts(self, low, high, factors, colors):
)
elif self.cnorm == 'eq_hist':
colormapper = EqHistColorMapper
- if bokeh_version > Version('2.4.2'):
- opts['rescale_discrete_levels'] = self.rescale_discrete_levels
+ opts['rescale_discrete_levels'] = self.rescale_discrete_levels
if isinstance(low, (bool, np.bool_)): low = int(low)
if isinstance(high, (bool, np.bool_)): high = int(high)
# Pad zero-range to avoid breaking colorbar (as of bokeh 1.0.4)
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
index 9cef2f3b6c..bbf8e27beb 100644
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -18,9 +18,10 @@
)
from holoviews import opts
-from holoviews.core import DynamicMap, HoloMap, NdOverlay, Overlay
+from holoviews.core import Dimension, DynamicMap, HoloMap, NdOverlay, Overlay
from holoviews.core.util import dt_to_int
from holoviews.element import Curve, HeatMap, Image, Labels, Scatter
+from holoviews.plotting.bokeh.util import bokeh34
from holoviews.plotting.util import process_cmap
from holoviews.streams import PointDraw, Stream
from holoviews.util import render
@@ -795,6 +796,76 @@ def test_element_backend_opts_model_not_resolved(self):
"WARNING", "cb model could not be"
)
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.skipif(not bokeh34, reason="requires Bokeh >= 3.4")
+class TestScalebarPlot:
+
+ def get_scalebar(self, element):
+ plot = bokeh_renderer.get_plot(element)
+ return plot.handles.get('scalebar')
+
+ def test_scalebar(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=True)
+ scalebar = self.get_scalebar(curve)
+ assert scalebar.visible
+ assert scalebar.location == 'bottom_right'
+ assert scalebar.background_fill_alpha == 0.8
+ assert scalebar.unit == "m"
+
+ def test_no_scalebar(self):
+ curve = Curve([1, 2, 3])
+ scalebar = self.get_scalebar(curve)
+ assert scalebar is None
+
+ def test_scalebar_unit(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=True, scalebar_unit='cm')
+ scalebar = self.get_scalebar(curve)
+ assert scalebar.visible
+ assert scalebar.unit == "cm"
+
+ def test_dim_unit(self):
+ dim = Dimension("dim", unit="cm")
+ curve = Curve([1, 2, 3], kdims=dim).opts(scalebar=True)
+ scalebar = self.get_scalebar(curve)
+ assert scalebar.visible
+ assert scalebar.unit == "cm"
+
+ def test_scalebar_custom_opts(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=True, scalebar_opts={'background_fill_alpha': 1})
+ scalebar = self.get_scalebar(curve)
+ assert scalebar.visible
+ assert scalebar.background_fill_alpha == 1
+
+ def test_scalebar_label(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=True, scalebar_label='Test')
+ scalebar = self.get_scalebar(curve)
+ assert scalebar.visible
+ assert scalebar.label == 'Test'
+
+ def test_scalebar_icon(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=True)
+ plot = bokeh_renderer.get_plot(curve)
+ toolbar = plot.handles['plot'].toolbar
+ scalebar_icon = [tool for tool in toolbar.tools if tool.description == "Toggle ScaleBar"]
+ assert len(scalebar_icon) == 1
+
+ def test_scalebar_no_icon(self):
+ curve = Curve([1, 2, 3]).opts(scalebar=False)
+ plot = bokeh_renderer.get_plot(curve)
+ toolbar = plot.handles['plot'].toolbar
+ scalebar_icon = [tool for tool in toolbar.tools if tool.description == "Toggle ScaleBar"]
+ assert len(scalebar_icon) == 0
+
+ def test_scalebar_icon_multiple_overlay(self):
+ curve1 = Curve([1, 2, 3]).opts(scalebar=True)
+ curve2 = Curve([1, 2, 3]).opts(scalebar=True)
+ plot = bokeh_renderer.get_plot(curve1 * curve2)
+ toolbar = plot.handles['plot'].toolbar
+ scalebar_icon = [tool for tool in toolbar.tools if tool.description == "Toggle ScaleBar"]
+ assert len(scalebar_icon) == 1
+
+
class TestColorbarPlot(LoggingComparisonTestCase, TestBokehPlot):
def test_colormapper_symmetric(self):