Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9d2fdcb

Browse files
authored
fix: Dynamic updating a selector HoverTool (#6593)
1 parent 401d00d commit 9d2fdcb

File tree

5 files changed

+127
-28
lines changed

5 files changed

+127
-28
lines changed

doc/user_guide/index.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ concepts in HoloViews:
6666
`Working with large data <Large_Data.html>`_
6767
Leverage Datashader to interactively explore millions or billions of datapoints.
6868

69+
`Interactive Hover for Big Data <Interactive_Hover_for_Big_Data.html>`_
70+
Use the ``selector`` with Datashader to enable fast, interactive hover tooltips that
71+
reveal individual data points without sacrificing aggregation.
72+
6973
`Working with Streaming Data <Streaming_Data.html>`_
7074
Demonstrates how to leverage the streamz library with HoloViews to work with streaming datasets.
7175

@@ -142,6 +146,7 @@ These guides provide detail about specific additional features in HoloViews:
142146
Data Processing Pipelines <Data_Pipelines>
143147
Creating interactive network graphs <Network_Graphs>
144148
Working with large data <Large_Data>
149+
Interactive Hover for Big Data <Interactive_Hover_for_Big_Data>
145150
Working with streaming data <Streaming_Data>
146151
Creating interactive dashboards <Dashboards>
147152
Customizing Plots <Customizing_Plots>

examples/user_guide/Interactive_Hover_for_Big_Data.ipynb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"This notebook uses dynamic updates, which require running a live Jupyter or Bokeh server. When viewed statically, the plots will not update, you can zoom and pan, and hover information will not be available. \n",
1919
":::\n",
2020
"\n",
21+
":::{note}\n",
22+
"This functionality requires Bokeh version 3.7 or greater.\n",
23+
":::\n",
24+
"\n",
2125
"Let's start by creating a Points element with a DataFrame consisting of five datasets combined. Each of the datasets has a random x, y-coordinate based on a normal distribution centered at a specific (x, y) location, with varying standard deviations. The datasets—labeled `d1` through `d5`—represent different clusters:\n",
2226
"\n",
2327
"- `d1` is tightly clustered around (2, 2) with a small spread of 0.03,\n",

holoviews/plotting/bokeh/raster.py

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import sys
2-
import uuid
32

43
import bokeh.core.properties as bp
54
import numpy as np
@@ -12,18 +11,42 @@
1211
from ...core.data import XArrayInterface
1312
from ...core.util import cartesian_product, dimension_sanitizer, isfinite
1413
from ...element import Raster
14+
from ...util.warnings import warn
1515
from ..util import categorical_legend
1616
from .chart import PointPlot
1717
from .element import ColorbarPlot, LegendPlot
1818
from .selection import BokehOverlaySelectionDisplay
1919
from .styles import base_properties, fill_properties, line_properties, mpl_to_bokeh
20-
from .util import BOKEH_GE_3_3_0, BOKEH_GE_3_4_0, BOKEH_GE_3_7_0, colormesh
20+
from .util import (
21+
BOKEH_GE_3_3_0,
22+
BOKEH_GE_3_4_0,
23+
BOKEH_GE_3_7_0,
24+
BOKEH_VERSION,
25+
colormesh,
26+
)
2127

2228
_EPOCH = np.datetime64("1970-01-01", "ns")
2329

2430

31+
class HoverModel(DataModel):
32+
xy = bp.Any()
33+
data = bp.Any()
34+
35+
code_js = """
36+
export default ({hover_model, attr, fmt}) => {
37+
const templating = Bokeh.require("core/util/templating");
38+
const value = hover_model.data[attr];
39+
if (fmt == "M") {
40+
const formatter = templating.DEFAULT_FORMATTERS.datetime;
41+
return formatter(value, "%Y-%m-%d %H:%M:%S")
42+
} else {
43+
const formatter = templating.get_formatter();
44+
return formatter(value)
45+
}
46+
}; """
47+
48+
2549
class ServerHoverMixin(param.Parameterized):
26-
_model_cache = {}
2750

2851
selector_in_hovertool = param.Boolean(default=True, doc="""
2952
Whether to show the selector in HoverTool.""")
@@ -52,6 +75,13 @@ def _init_tools(self, element, callbacks=None):
5275
):
5376
return tools
5477

78+
if not BOKEH_GE_3_7_0:
79+
bk_str = ".".join(map(str, BOKEH_VERSION))
80+
msg = f"selector needs Bokeh 3.7 or greater, you are using version {bk_str}."
81+
warn(msg, RuntimeWarning)
82+
hover.tooltips = Div(children=[msg])
83+
return tools
84+
5585
self._hover_data = data
5686

5787
# Get dimensions
@@ -69,34 +99,21 @@ def _init_tools(self, element, callbacks=None):
6999
if vdim in vars:
70100
vars.remove(vdim)
71101

102+
hover_model = HoverModel(data={})
72103
dims = (*coords, *vars)
73-
74-
# Create a dynamic custom DataModel with the dims as attributes
75-
# __xy__ is the cursor position
76-
if dims in self._model_cache:
77-
HoverModel = self._model_cache[dims]
78-
else:
79-
HoverModel = self._model_cache[dims] = type(
80-
f"HoverModel_{uuid.uuid4().hex}",
81-
(DataModel,),
82-
{d: bp.Any() for d in ("__xy__", *dims)},
83-
)
84-
85-
hover_model = HoverModel()
86104
dtypes = {**data.coords.dtypes, **data.data_vars.dtypes}
87105
is_datetime = [dtypes[c].kind == "M" for c in data.coords]
88106
def _create_row(attr):
89-
kwargs = {}
90-
if BOKEH_GE_3_7_0:
91-
kind = dtypes[attr].kind
92-
if kind in "uifO":
93-
kwargs["formatter"] = "basic"
94-
elif kind == "M":
95-
kwargs["formatter"] = "datetime"
96-
kwargs["format"] = "%Y-%m-%d %H:%M:%S"
107+
kwargs = {
108+
"format": "@{custom}",
109+
"formatter": CustomJS(
110+
args=dict(hover_model=hover_model, attr=attr, fmt=dtypes[attr].kind),
111+
code=HoverModel.code_js
112+
)
113+
}
97114
return (
98115
Span(children=[f"{ht.get(attr, attr)}:"], style={"color": "#26aae1", "text_align": "right"}),
99-
Span(children=[ValueOf(obj=hover_model, attr=attr, **kwargs)], style={"text_align": "left"}),
116+
Span(children=[ValueOf(obj=hover_model, attr="data", **kwargs)], style={"text_align": "left"}),
100117
)
101118
children = [el for dim in dims for el in _create_row(dim)]
102119

@@ -136,7 +153,7 @@ def _create_row(attr):
136153
hover.tooltips = grid
137154
hover.callback = CustomJS(
138155
args={"position": hover_model},
139-
code="export default ({position}, _, {geometry: {x, y}}) => {position.__xy__ = [x, y]}",
156+
code="export default ({position}, _, {geometry: {x, y}}) => {position.xy = [x, y]}",
140157
)
141158

142159
def on_change(attr, old, new):
@@ -157,11 +174,11 @@ def on_change(attr, old, new):
157174
data_coords = {dim: data_sel['coords'][dim]['data'] for dim in coords}
158175
data_vars = {dim: data_sel['data_vars'][dim]['data'] for dim in vars}
159176
with hold(self.document):
160-
hover_model.update(**data_coords, **data_vars)
177+
hover_model.update(data={**data_coords, **data_vars})
161178
if self.comm: # Jupyter Notebook
162179
self.push()
163180

164-
hover_model.on_change("__xy__", on_change)
181+
hover_model.on_change("xy", on_change)
165182

166183
return tools
167184

holoviews/tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ def serve_and_return_page(hv_obj):
124124

125125
return serve_and_return_page
126126

127+
@pytest.fixture
128+
def serve_panel(page, port): # noqa: F811
129+
def serve_and_return_page(pn_obj):
130+
serve_and_wait(pn.panel(pn_obj), port=port)
131+
page.goto(f"http://localhost:{port}")
132+
return page
133+
134+
return serve_and_return_page
127135

128136
@pytest.fixture(autouse=True)
129137
def reset_store():

holoviews/tests/ui/bokeh/test_hover.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pandas as pd
3+
import panel as pn
34
import pytest
45

56
import holoviews as hv
@@ -363,3 +364,67 @@ def test_hover_tooltips_rasterize_server_datetime_axis(serve_hv, rng, convert_x,
363364
expect(page.locator(".bk-Tooltip")).to_contain_text('x:2020-01-01')
364365
if convert_y:
365366
expect(page.locator(".bk-Tooltip")).to_contain_text('y:2020-01-01')
367+
368+
369+
@pytest.mark.usefixtures("bokeh_backend")
370+
def test_hover_tooltips_selector_update_plot(serve_panel):
371+
import datashader as ds
372+
373+
from holoviews.operation.datashader import rasterize
374+
375+
N_OBS = 1000
376+
x_data = np.random.random((N_OBS, N_OBS))
377+
378+
def get_plot(color_by):
379+
if color_by == 'option1':
380+
color_data = np.random.choice(['A', 'B', 'C', 'D'], size=N_OBS)
381+
else:
382+
color_data = np.random.choice(['a', 'b', 'c', 'd'], size=N_OBS)
383+
384+
dataset = hv.Dataset(
385+
(x_data[:, 0], x_data[:, 1], color_data),
386+
['X', 'Y'],
387+
color_by,
388+
)
389+
plot = dataset.to(hv.Points)
390+
plot = rasterize(
391+
plot,
392+
aggregator=ds.count_cat(color_by),
393+
selector=ds.first('X'),
394+
)
395+
plot = plot.opts(tools=["hover"], title=color_by)
396+
return plot
397+
398+
scb = pn.widgets.Select(name="Color By", options=['option1', 'option2'])
399+
layout = pn.Row(scb, pn.bind(get_plot, scb))
400+
401+
page = serve_panel(layout)
402+
page.wait_for_timeout(500)
403+
404+
# Locate the plot and move mouse over it
405+
hv_plot = page.locator(".bk-events")
406+
expect(hv_plot).to_have_count(1)
407+
bbox = hv_plot.bounding_box()
408+
page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
409+
page.wait_for_timeout(200)
410+
411+
tooltip = page.locator(".bk-Tooltip")
412+
expect(tooltip).to_have_count(1)
413+
expect(tooltip).to_contain_text('A:')
414+
expect(tooltip).to_contain_text('B:')
415+
expect(tooltip).to_contain_text('C:')
416+
expect(tooltip).to_contain_text('D:')
417+
418+
# Change the selector to 'option2'
419+
scb.value = "option2"
420+
page.wait_for_timeout(500)
421+
422+
# Move the mouse again to trigger updated tooltip
423+
page.mouse.move(bbox["x"] + bbox["width"] / 4, bbox["y"] + bbox["height"] / 4)
424+
page.wait_for_timeout(200)
425+
tooltip = page.locator(".bk-Tooltip")
426+
expect(tooltip).to_have_count(1)
427+
expect(tooltip).to_contain_text('a:')
428+
expect(tooltip).to_contain_text('b:')
429+
expect(tooltip).to_contain_text('c:')
430+
expect(tooltip).to_contain_text('d:')

0 commit comments

Comments
 (0)