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):