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