diff --git a/examples/gallery/demos/bokeh/html_hover_tooltips.ipynb b/examples/gallery/demos/bokeh/html_hover_tooltips.ipynb
new file mode 100644
index 0000000000..767ed2b223
--- /dev/null
+++ b/examples/gallery/demos/bokeh/html_hover_tooltips.ipynb
@@ -0,0 +1,113 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import holoviews as hv\n",
+ "\n",
+ "hv.extension(\"bokeh\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "This demo demonstrates how to build custom hover tooltips using HTML. The\n",
+ "tooltips are displayed when the user hovers over a point in the plot."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Declare data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "df = pd.DataFrame(\n",
+ " dict(\n",
+ " x=[1, 2, 3, 4, 5],\n",
+ " y=[2, 5, 8, 2, 7],\n",
+ " desc=[\"A\", \"b\", \"C\", \"d\", \"E\"],\n",
+ " imgs=[\n",
+ " \"https://docs.bokeh.org/static/snake.jpg\",\n",
+ " \"https://docs.bokeh.org/static/snake2.png\",\n",
+ " \"https://docs.bokeh.org/static/snake3D.png\",\n",
+ " \"https://docs.bokeh.org/static/snake4_TheRevenge.png\",\n",
+ " \"https://docs.bokeh.org/static/snakebite.jpg\",\n",
+ " ],\n",
+ " fonts=[\n",
+ " \"italics\",\n",
+ " \"
pre
\",\n",
+ " \"bold\",\n",
+ " \"small\",\n",
+ " \"del\",\n",
+ " ],\n",
+ " )\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Declare plot\n",
+ "\n",
+ "Having declared the tooltips' columns, we can reference them in the tooltips with `@`. Just be sure to pass *all the relevant columns* as extra `vdims` ."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\n",
+ "TOOLTIPS = \"\"\"\n",
+ " \n",
+ " $label\n",
+ "
\n",
+ "
\n",
+ "
\n",
+ "
\n",
+ " @desc\n",
+ " [$index]\n",
+ "
\n",
+ "
\n",
+ " @fonts{safe}\n",
+ "
\n",
+ "
\n",
+ " Location\n",
+ " ($x, $y)\n",
+ "
\n",
+ "
\n",
+ "\"\"\"\n",
+ "\n",
+ "hv.Scatter(df, kdims=[\"x\"], vdims=[\"y\", \"desc\", \"imgs\", \"fonts\"], label=\"Pictures\").opts(\n",
+ " hover_tooltips=TOOLTIPS, size=20\n",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "language_info": {
+ "name": "python",
+ "pygments_lexer": "ipython3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/user_guide/Plotting_with_Bokeh.ipynb b/examples/user_guide/Plotting_with_Bokeh.ipynb
index 1e7fa587b6..b13c1edbed 100644
--- a/examples/user_guide/Plotting_with_Bokeh.ipynb
+++ b/examples/user_guide/Plotting_with_Bokeh.ipynb
@@ -652,7 +652,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "Additionally, you can provide `'vline'`, the equivalent of passing `HoverTool(mode='vline')`, or `'hline'` to set the hit-testing behavior"
+ "Moreover, you can provide `'vline'`, the equivalent of passing `HoverTool(mode='vline')`, or `'hline'` to set the hit-testing behavior."
]
},
{
@@ -661,27 +661,142 @@
"metadata": {},
"outputs": [],
"source": [
- "error = np.random.rand(100, 3)\n",
- "heatmap_data = {(chr(65+i), chr(97+j)):i*j for i in range(5) for j in range(5) if i!=j}\n",
- "data = [np.random.normal() for i in range(10000)]\n",
- "hist = np.histogram(data, 20)\n",
+ "hv.Curve(np.arange(100)).opts(tools=[\"vline\"]) + hv.Curve(np.arange(100)).opts(tools=[\"hline\"])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Equivalently, you may say `tools=[\"hover\"]` alongside `hover_mode`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "(\n",
+ " hv.Curve(np.arange(100)).opts(tools=[\"hover\"], hover_mode=\"vline\")\n",
+ " + hv.Curve(np.arange(100)).opts(tools=[\"hover\"], hover_mode=\"hline\")\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "If you'd like finer control on the formatting, you may use `hover_tooltips` to declare the tooltips as a list of tuples of the labels and a specification of the dimension name and how to display it.\n",
"\n",
- "points = hv.Points(error)\n",
- "heatmap = hv.HeatMap(heatmap_data).sort()\n",
- "histogram = hv.Histogram(hist)\n",
- "image = hv.Image(np.random.rand(50,50))\n",
+ "Behind the scenes, the `hover_tooltips` feature extends the capabilities of Bokeh's `HoverTool` tooltips by providing additional flexibility and customization options, so for a reference see the [bokeh user guide](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html#hovertool)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "hover_tooltips = [\n",
+ " ('Name', '@name'),\n",
+ " ('Symbol', '@symbol'),\n",
+ " ('CPK', '$color[hex, swatch]:CPK')\n",
+ "]\n",
"\n",
- "(points + heatmap + histogram + image).opts(\n",
- " opts.Points(tools=['hline'], size=5), opts.HeatMap(tools=['hover']),\n",
- " opts.Image(tools=['vline']), opts.Histogram(tools=['hover']),\n",
- " opts.Layout(shared_axes=False)).cols(2)"
+ "points.clone().opts(\n",
+ " tools=[\"hover\"], hover_tooltips=hover_tooltips, color='metal', cmap='Category20',\n",
+ " line_color='black', size=dim('atomic radius')/10,\n",
+ " width=600, height=400, show_grid=True,\n",
+ " title='Chemical Elements by Type (scaled by atomic radius)')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Unique from Bokeh's `HoverTool`, the HoloViews' `hover_tooltips` also supports a mix of string and tuple formats for defining tooltips, allowing for both direct references to data columns and customized display options.\n",
+ "\n",
+ "Additionally, you can include as many, or as little, dimension names as desired."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "hover_tooltips = [\n",
+ " \"name\", # will assume @name\n",
+ " (\"Symbol\", \"@symbol\"), # @ still required if tuple\n",
+ " ('CPK', '$color[hex, swatch]:CPK'),\n",
+ " \"density\"\n",
+ "]\n",
+ "\n",
+ "points.clone().opts(\n",
+ " tools=[\"hover\"], hover_tooltips=hover_tooltips, color='metal', cmap='Category20',\n",
+ " line_color='black', size=dim('atomic radius')/10,\n",
+ " width=600, height=400, show_grid=True,\n",
+ " title='Chemical Elements by Type (scaled by atomic radius)')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "`hover_tooltips` also support displaying the HoloViews element's `label` and `group`.\n",
+ "\n",
+ "Keep in mind, to reference these special variables that are not based on the data, a prefix of `$` is required!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "a_curve = hv.Curve([0, 1, 2], label=\"A\", group=\"C\")\n",
+ "b_curve = hv.Curve([2, 1, 0], label=\"B\", group=\"C\")\n",
+ "(a_curve * b_curve).opts(\"Curve\", hover_tooltips=[\"$label\", \"$group\", \"@x\", \"y\"]) # $ is required, @ is not needed for string"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "If you need special formatting, you may also specify the formats inside `hover_tooltips` alongside `hover_formatters`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def datetime(x):\n",
+ " return np.array(x, dtype=np.datetime64)\n",
+ "\n",
+ "\n",
+ "df = pd.DataFrame(\n",
+ " {\n",
+ " \"date\": [\"2019-01-01\", \"2019-01-02\", \"2019-01-03\"],\n",
+ " \"adj_close\": [100, 101, 100000],\n",
+ " }\n",
+ ")\n",
+ "\n",
+ "curve = hv.Curve((datetime(df[\"date\"]), df[\"adj_close\"]), \"date\", \"adj close\")\n",
+ "curve.opts(\n",
+ " hover_tooltips=[\"date\", (\"Close\", \"$@{adj close}{0.2f}\")], # use @{ } for dims with spaces\n",
+ " hover_formatters={\"@{adj close}\": \"printf\"}, # use 'printf' formatter for '@{adj close}' field\n",
+ " hover_mode=\"vline\",\n",
+ ")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "It is also possible to explicitly declare the columns to display by manually constructing a `HoverTool` and declaring the tooltips as a list of tuples of the labels and a specification of the dimension name and how to display it (for a complete reference see the [bokeh user guide](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html#hovertool))."
+ "You can provide HTML strings too! See a demo [here](../gallery/demos/bokeh/html_hover_tooltips.ipynb), or explicitly declare the columns to display by manually constructing a Bokeh [`HoverTool`](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html#hovertool)."
]
},
{
@@ -695,7 +810,7 @@
"\n",
"points = hv.Points(\n",
" elements, ['electronegativity', 'density'],\n",
- " ['name', 'symbol', 'metal', 'CPK', 'atomic radius']\n",
+ " ['name', 'symbol', 'metal', 'CPK', 'atomic radius'],\n",
").sort('metal')\n",
"\n",
"tooltips = [\n",
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
index 05972603ec..4c6773ff25 100644
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -230,6 +230,15 @@ class ElementPlot(BokehPlot, GenericElementPlot):
tools = param.List(default=[], doc="""
A list of plugin tools to use on the plot.""")
+ hover_tooltips = param.ClassSelector(class_=(list, str), doc="""
+ A list of dimensions to be displayed in the hover tooltip.""")
+
+ hover_formatters = param.Dict(doc="""
+ A dict of formatting options for the hover tooltip.""")
+
+ hover_mode = param.ObjectSelector(default='mouse', objects=['mouse', 'vline', 'hline'], doc="""
+ The hover mode determines how the hover tool is activated.""")
+
toolbar = param.ObjectSelector(default='right',
objects=["above", "below",
"left", "right", "disable", None],
@@ -286,16 +295,139 @@ def _hover_opts(self, element):
dims += element.dimensions()
return list(util.unique_iterator(dims)), {}
+ def _replace_hover_label_group(self, element, tooltip):
+ if isinstance(tooltip, tuple):
+ has_label = hasattr(element, 'label') and element.label
+ has_group = hasattr(element, 'group') and element.group != element.param.group.default
+ if not has_label and not has_group:
+ return tooltip
+
+ if ("$label" in tooltip or "${label}" in tooltip):
+ tooltip = (tooltip[0], element.label)
+ elif ("$group" in tooltip or "${group}" in tooltip):
+ tooltip = (tooltip[0], element.group)
+ elif isinstance(tooltip, str):
+ if "$label" in tooltip:
+ tooltip = tooltip.replace("$label", element.label)
+ elif "${label}" in tooltip:
+ tooltip = tooltip.replace("${label}", element.label)
+
+ if "$group" in tooltip:
+ tooltip = tooltip.replace("$group", element.group)
+ elif "${group}" in tooltip:
+ tooltip = tooltip.replace("${group}", element.group)
+ return tooltip
+
+ def _replace_hover_value_aliases(self, tooltip, tooltips_dict):
+ for name, tuple_ in tooltips_dict.items():
+ # some elements, like image, rename the tooltip, e.g. @y -> $y
+ # let's replace those, so the hover tooltip is discoverable
+ # ensure it works for `(@x, @y)` -> `($x, $y)` too
+ if isinstance(tooltip, tuple):
+ value_alias = tuple_[1]
+ if f"@{name}" in tooltip[1]:
+ tooltip = (tooltip[0], tooltip[1].replace(f"@{name}", value_alias))
+ elif f"@{{{name}}}" in tooltip[1]:
+ tooltip = (tooltip[0], tooltip[1].replace(f"@{{{name}}}", value_alias))
+ elif isinstance(tooltip, str):
+ if f"@{name}" in tooltip:
+ tooltip = tooltip.replace(f"@{name}", tuple_[1])
+ elif f"@{{{name}}}" in tooltip:
+ tooltip = tooltip.replace(f"@{{{name}}}", tuple_[1])
+ return tooltip
+
+ def _prepare_hover_kwargs(self, element):
+ tooltips, hover_opts = self._hover_opts(element)
+
+ dim_aliases = {
+ f"{dim.label} ({dim.unit})" if dim.unit else dim.label: dim.name
+ for dim in element.kdims + element.vdims
+ }
+
+ # make dict so it's easy to get the tooltip for a given dimension;
+ tooltips_dict = {}
+ units_dict = {}
+ for ttp in tooltips:
+ if isinstance(ttp, tuple):
+ name = ttp[0]
+ tuple_ = (ttp[0], ttp[1])
+ elif isinstance(ttp, Dimension):
+ name = ttp.name
+ # three brackets means replacing variable,
+ # and then wrapping in brackets, like @{air}
+ unit = f" ({ttp.unit})" if ttp.unit else ""
+ tuple_ = (
+ ttp.pprint_label,
+ f"@{{{util.dimension_sanitizer(ttp.name)}}}"
+ )
+ units_dict[name] = unit
+ elif isinstance(ttp, str):
+ name = ttp
+ # three brackets means replacing variable,
+ # and then wrapping in brackets, like @{air}
+ tuple_ = (ttp.name, f"@{{{util.dimension_sanitizer(ttp)}}}")
+
+ if name in dim_aliases:
+ name = dim_aliases[name]
+
+ # key is the vanilla data column/dimension name
+ # value should always be a tuple (label, value)
+ tooltips_dict[name] = tuple_
+
+ # subset the tooltips to only the ones user wants
+ if self.hover_tooltips:
+ # If hover tooltips are defined as a list of strings or tuples
+ if isinstance(self.hover_tooltips, list):
+ new_tooltips = []
+ for tooltip in self.hover_tooltips:
+ if isinstance(tooltip, str):
+ # make into a tuple
+ new_tooltip = tooltips_dict.get(tooltip.lstrip("@"))
+ if new_tooltip is None:
+ label = tooltip.lstrip("$").lstrip("@")
+ value = tooltip if "$" in tooltip else f"@{{{tooltip.lstrip('@')}}}"
+ new_tooltip = (label, value)
+ new_tooltips.append(new_tooltip)
+ elif isinstance(tooltip, tuple):
+ unit = units_dict.get(tooltip[0])
+ tooltip = self._replace_hover_value_aliases(tooltip, tooltips_dict)
+ if unit:
+ tooltip = (f"{tooltip[0]}{unit}", tooltip[1])
+ new_tooltips.append(tooltip)
+ else:
+ raise ValueError('Hover tooltips must be a list with items of strings or tuples.')
+ tooltips = new_tooltips
+ else:
+ # Likely HTML str
+ tooltips = self._replace_hover_value_aliases(self.hover_tooltips, tooltips_dict)
+ else:
+ tooltips = list(tooltips_dict.values())
+
+ # replace the label and group in the tooltips
+ if isinstance(tooltips, list):
+ tooltips = [self._replace_hover_label_group(element, ttp) for ttp in tooltips]
+ elif isinstance(tooltips, str):
+ tooltips = self._replace_hover_label_group(element, tooltips)
+
+ if self.hover_formatters:
+ hover_opts['formatters'] = self.hover_formatters
+
+ if self.hover_mode:
+ hover_opts["mode"] = self.hover_mode
+
+ return tooltips, hover_opts
+
def _init_tools(self, element, callbacks=None):
"""
Processes the list of tools to be supplied to the plot.
"""
if callbacks is None:
callbacks = []
- tooltips, hover_opts = self._hover_opts(element)
- tooltips = [(ttp.pprint_label, '@{%s}' % util.dimension_sanitizer(ttp.name))
- if isinstance(ttp, Dimension) else ttp for ttp in tooltips]
- if not tooltips: tooltips = None
+
+ tooltips, hover_opts = self._prepare_hover_kwargs(element)
+
+ if not tooltips:
+ tooltips = None
callbacks = callbacks+self.callbacks
cb_tools, tool_names = [], []
@@ -314,13 +446,23 @@ def _init_tools(self, element, callbacks=None):
cb_tools.append(tool)
self.handles[handle] = tool
+ all_tools = cb_tools + self.default_tools + self.tools
+ if self.hover_tooltips:
+ no_hover = (
+ "hover" not in all_tools and
+ not (any(isinstance(tool, tools.HoverTool) for tool in all_tools))
+ )
+ if no_hover:
+ all_tools.append("hover")
+
tool_list = []
- for tool in cb_tools + self.default_tools + self.tools:
+ for tool in all_tools:
if tool in tool_names:
continue
if tool in ['vline', 'hline']:
+ tool_opts = dict(hover_opts, mode=tool)
tool = tools.HoverTool(
- tooltips=tooltips, tags=['hv_created'], mode=tool, **hover_opts
+ tooltips=tooltips, tags=['hv_created'], **tool_opts
)
elif bokeh32 and isinstance(tool, str) and tool.endswith(
('wheel_zoom', 'zoom_in', 'zoom_out')
@@ -392,9 +534,7 @@ def _init_tools(self, element, callbacks=None):
def _update_hover(self, element):
tool = self.handles['hover']
if 'hv_created' in tool.tags:
- tooltips, hover_opts = self._hover_opts(element)
- tooltips = [(ttp.pprint_label, '@{%s}' % util.dimension_sanitizer(ttp.name))
- if isinstance(ttp, Dimension) else ttp for ttp in tooltips]
+ tooltips, hover_opts = self._prepare_hover_kwargs(element)
tool.tooltips = tooltips
else:
plot_opts = element.opts.get('plot', 'bokeh')
diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py
new file mode 100644
index 0000000000..302599fe1f
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_hover.py
@@ -0,0 +1,234 @@
+import time
+
+import numpy as np
+import pytest
+
+import holoviews as hv
+
+from .. import expect, wait_until
+
+pytestmark = pytest.mark.ui
+
+
+def delay_rerun(*args):
+ time.sleep(2)
+ return True
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_list(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+ ).opts(hover_tooltips=["$x", "xc", "@yc", "@z"])
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("xc:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("yc:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("z:")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_unit_format(serve_hv):
+ dim = hv.Dimension("Test", unit="Unit")
+ hv_image = hv.Image(
+ np.zeros((10, 10)), bounds=(0, 0, 1, 1), kdims=["xc", "yc"], vdims=[dim]
+ ).opts(hover_tooltips=[("Test", "@Test{%0.2f}")])
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("Test: 0.00%")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_list_mix_tuple_string(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+ ).opts(hover_tooltips=[("xs", "($x, @xc)"), "yc", "z"])
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("xs:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("yc:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("z:")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_label_group(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10),
+ bounds=(0, 0, 1, 1),
+ kdims=["xc", "yc"],
+ label="Image Label",
+ group="Image Group",
+ ).opts(
+ hover_tooltips=[
+ "$label",
+ "$group",
+ ("Plot Label", "$label"),
+ ("Plot Group", "$group"),
+ ]
+ )
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("label:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("group:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("Plot Label:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("Plot Group:")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_missing(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+ ).opts(hover_tooltips=["abc"])
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_html_string(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+ ).opts(hover_tooltips="x: $x
y: @yc")
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("y:")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_formatters(serve_hv):
+ hv_image = hv.Image(
+ np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+ ).opts(
+ hover_tooltips=[("X", "($x, @xc{%0.3f})")], hover_formatters={"@xc": "printf"}
+ )
+
+ page = serve_hv(hv_image)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("X:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("%")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize("hover_mode", ["hline", "vline"])
+def test_hover_mode(serve_hv, hover_mode):
+ hv_curve = hv.Curve([0, 10, 2]).opts(tools=["hover"], hover_mode=hover_mode)
+
+ page = serve_hv(hv_curve)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+ expect(page.locator(".bk-Tooltip")).to_contain_text("y:")
+ expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+ "hover_tooltip",
+ [
+ "Amplitude",
+ "@Amplitude",
+ ("Amplitude", "@Amplitude"),
+ ],
+)
+def test_hover_tooltips_dimension_unit(serve_hv, hover_tooltip):
+ amplitude_dim = hv.Dimension("Amplitude", unit="µV")
+ hv_curve = hv.Curve([0, 10, 2], vdims=[amplitude_dim]).opts(
+ hover_tooltips=[hover_tooltip], hover_mode="vline"
+ )
+
+ page = serve_hv(hv_curve)
+ hv_plot = page.locator(".bk-events")
+ wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+ bbox = hv_plot.bounding_box()
+
+ # Hover over the plot
+ page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+ page.mouse.up()
+
+ wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+ expect(page.locator(".bk-Tooltip")).to_contain_text("Amplitude (µV): 10")