Linked data ---> one to many hover--> Scatter equivalent to MultiLine?

This question is somewhat similar to:

Consider the example below:

import bokeh.plotting
import bokeh.io
import bokeh.layouts
from bokeh.models import HoverTool
from bokeh.models import ColumnDataSource

# Data for plot
data = {'xl':[[3,4,5],[6,7,8]]
        ,'yl':[[1,3,2],[4,5,6]]
        ,'xp':[1,2]
        ,'yp':[3,4]
        ,'c':['blue','red']}
s1 = ColumnDataSource(data)

# make plots
p1 = bokeh.plotting.figure()
r1 = p1.multi_line(xs='xl', ys='yl', width=2, line_color='c',hover_color='green',source=s1)

p2 = bokeh.plotting.figure()
r2 = p2.scatter(x='xp', y='yp', size=20,fill_color='c',hover_color='green',source=s1)

# Add hovers
h1 = HoverTool(renderers=[r1])
h1.tooltips = ([('xl', '@xl}'), ('yl', '@yl')])
p1.add_tools(h1)

h2 = HoverTool(renderers=[r2])
h2.tooltips = ([('xp', '@xp}'), ('yp', '@yp')])
p2.add_tools(h2)
# Plot
layout = bokeh.layouts.layout([p1,p2])
bokeh.io.show(layout)

Here, I’m creating two plots, one with a multiline glyph and another with a scatter glyph. Both use the same source but use different fields in the datasource to drive their respective glyphs. This is amazing - hover over one line highlights its equivalent point on the other plot, and hover over a point highlights its corresponding line in the other plot. I cannot stress how amazing this is for my field of hydrogeology where we look at water level over time on one plot (lines) and want to see its corresponding location in plan view (pts).

I’ve run into a situation where instead of lines on p1, I want points. Essentially I want each line vertice to be a scatter point, and I want the hover behaviour to remain the same (i.e. hover over a point in p2, all corresponding points trigger hover on p1). My current workaround/partial solution is pretty dirty - I’m just flattening everything prior to passing it to the datasource:

import bokeh.plotting
import bokeh.io
import bokeh.layouts
from bokeh.models import HoverTool
from bokeh.models import ColumnDataSource
import numpy as np

data = {'xl':np.array([[3,4,5],[6,7,8]]).flatten()
        ,'yl':np.array([[1,3,2],[4,5,6]]).flatten()
        ,'xp':[1,1,1,2,2,2]
        ,'yp':[3,3,3,4,4,4]
        ,'c':['blue','blue','blue','red','red','red']}


s1 = ColumnDataSource(data)

# make plots
p1 = bokeh.plotting.figure()
r1 = p1.scatter(x='xl', y='yl', size=10, fill_color='c',hover_color='green',source=s1)

p2 = bokeh.plotting.figure()
r2 = p2.scatter(x='xp', y='yp', size=20,fill_color='c',hover_color='green',source=s1)

# Add hovers
h1 = HoverTool(renderers=[r1])
h1.tooltips = ([('xl', '@xl}'), ('yl', '@yl')])
p1.add_tools(h1)

h2 = HoverTool(renderers=[r2])
h2.tooltips = ([('xp', '@xp}'), ('yp', '@yp')])
p2.add_tools(h2)
# Plot
layout = bokeh.layouts.layout([p1,p2])
bokeh.io.show(layout)

This “kinda” works - on p2 (the right plot), I’m just stacking multiple points on top of each other. The hover on p2 gets pretty ugly as a result, and of course this is not very efficient.

I’m wondering if there are any suggestions to improving my workaround. I essentially need a one to many hover relationship between two renderers, which are both ideally built from the same datasource… OR i need a “MultiScatter” type model that works the same way as MultiLine. Any input much appreciated! Thanks!

1 Like

I’m facing exactly the same issue; a one-to-many hover linking would be useful.
As a quick workaround, is there a way to remove duplicate information in the HoverTool() when stacking the multiple glyphs on top of eachother?

I definitely have come up with a few interim workarounds up my sleeve using CustomJSHover/CustomJS on the callback property of the HoverTool since this post. If you can describe your needs and accompany w an MRE, I might be able to help :smiley:

I was unaware CustomJSHover existed, thank you for mentioning it.
You can see my issue in the picture attached. Each large hexagon on the right has associated many diamonds on the left (the wu/wv coordinate system is identical in both plots). When I hover the mouse on the right hexagons, all diamonds also change color, as intended. However, this is achieved due to data duplication on the right plot: for each diamond on the left I superimpose a hexagon on the right; this in turn explains the duplicated values in the hover text. This example shows an hexagon with only three diamonds, but this number can go up to 48, where the hover text covers the entire plot.

To remove the duplicates I tried the following Javascript snippet on a CustomJSHover instance, where I want to keep only the first value of coordinate wu (same for wv):

        hover_code = """
        var wu_new = special_vars.wu;
        return wu[0];
        """

The above does not work (the hover simply disappears on the right plot), and given the lack of duplication removal examples I am not even sure I can “filter” the hover text in this way.

So this isn’t doing the hot fix what you asked for, but instead it goes to the “heart” of the problem → i.e. linking separate sources on hover via some key field (I call it id_field in the example).

The JS is a bit complex but I commented it pretty heavily and remember to console.log the crap out of it to see what’s going on. I have found this setup suuuper useful beyond just linking hovers so I’m happy to share it!

# -*- coding: utf-8 -*-
"""
Created on Tue Feb 22 11:09:23 2022

@author: Gaelen
"""

from bokeh.models import ColumnDataSource, Scatter, CustomJS,HoverTool
from bokeh.transform import factor_cmap
from bokeh.plotting import figure, show, save
from bokeh.layouts import layout

cmap= factor_cmap(field_name='loc_id',factors=['A','B','C'],palette=['red','green','blue'])
#two sources, but they are related through id field
s1 = ColumnDataSource(data={'loc_id':['A','B','C'],'x':[1,2,3],'y':[1,3,2]})
s2 = ColumnDataSource(data={'loc_id':['A','A','A','B','B','B','B','C','C'],'xxs':[1,1,1,2,2,2,2,3,3],'yys':[10,8,4,9,6,5,2,9,4]})

f = figure()


f1g = Scatter(x='x',y='y',size=10,marker='square',fill_color=cmap)
f1hg = Scatter(x='x',y='y',fill_color='purple',size=10)
f1r = f.add_glyph(s1,f1g,hover_glyph=f1hg)

f2g = Scatter(x='xxs',y='yys',size=10,fill_color=cmap)
f2hg = Scatter(x='xxs',y='yys',fill_color='purple',size=10)
f2r = f.add_glyph(s2,f2g,hover_glyph=f2hg)




#CustomJS 
cb = CustomJS(args=dict(srcs=[s1,s2],id_field='loc_id',include_self=True)
                         ,code="""
                         // "cb_data" is a reserved thing for a hover callback
                         var hvr_inds = cb_data.index.indices //gets the indices of the thing being hovered
                         // if you get a "hit"...
                         if (hvr_inds.length>0){
                                 //get the source of the thing that's hovered, i.e. s1 or s2                                 
                                 var hvr_src = cb_data.renderer.data_source
                                 //get the loc id values that correspond to the hovered indices
                                 var loc_ids = hvr_inds.map(x=>hvr_src.data[id_field][x])                                 
                                 //now we basically just need to update the inspected indices of the other src to indices where that id occurs
                                 for (var si=0;si<srcs.length;si++){                                         
                                         var src = srcs[si]
                                         //if the source is NOT the hovered source... want to update it
                                         if (src != hvr_src){
                                                 //get the indices on this source where the loc_ids are included
                                                 // kinda a tricky reduce function but this helped me --> https://stackoverflow.com/questions/47917535/get-indexes-of-filtered-array-items
                                                 
                                                 var inds = src.data[id_field].reduce(function(acc,curr,i){
                                                                                             if (loc_ids.includes(curr)){
                                                                                                     acc.push(i)
                                                                                                     }
                                                                                             return acc}, [])
                                                 //set the inspected indices to this
                                                 src.inspected.indices = inds
                                                 src.change.emit()
                                                 }
                                         //if "include_self" is true, will trigger hover of other indices in the hovered source that match the locID
                                         // try setting include_self to False in the args dict to see what I mean
                                         if (src == hvr_src && include_self == true){
                                                 //copy paste basically
                                                 var inds = src.data[id_field].reduce(function(acc,curr,i){
                                                                                             if (loc_ids.includes(curr)){
                                                                                                     acc.push(i)
                                                                                                     }
                                                                                             return acc}, [])
                                                 //set the inspected indices to this
                                                 src.inspected.indices = inds
                                                 src.change.emit()
                                                 }
                                         }
                                 
                                 }
                         //if you don't get a "hit", want to "uninspect" everything
                         else {
                                 for (var si = 0; si<srcs.length; si++){
                                         if (src != hvr_src){
                                                 src.inspected.indices = []
                                                 src.change.emit()
                                                 }
                                         }
                                 }
                           
                        """)
hvr = HoverTool(renderers=[f1r,f2r],tooltips=[('','@loc_id')],callback=cb)
                        



f.add_tools(hvr)


save(f,r'C:\Repo\Proofing\ManyHover.html')

one_to_many

1 Like