Linked HoverTool Callback Only Showing For One Series

Hi there,

I am attempting to link a scatter and time series plot so I can mouse over a point in the scatter plot and highlight the corresponding points in the time series plot. This does work for my second series (orange), but not for the blue series. Steps to reproduce:

  1. Run code below.
  2. Mouseover any orange point on the left hand scatter plot. Observe in the console that the circles have been updated with the data. Press F8 (Chrome) to continue and see two black rings appear in the right hand time series chart.
  3. Mouseover any blue point on the left hand scatter plot. Observe in the console that the circles have been updated with the data. Press F8 (Chrome) to continue and this time no black rings appear on the right hand chart. Why? It seems as if the data gets overwritten because if you remove the “debugger;” line from the callback code (or inspect the circles data outside of the callback) the circles data is empty when you mouseover a blue point.

Any help would be appreciated. Thanks!

# Imports
import numpy as np
import pandas as pd

# Plotting
from bokeh.palettes import Category20 as colours
from bokeh.models import ColumnDataSource, Legend, HoverTool, CustomJS, DatetimeTickFormatter
from bokeh.plotting import figure, show
from bokeh.layouts import row

# Make data frame of test data
df = pd.DataFrame(data = {'device': ['abc'] * 3 + ['def'] * 5, 'timestamp': [f'2017-01-01 {i:02.0f}:00:00' for i in range(0, 8)], 'value_x': 100 * np.random.random(size = (8,)), 'value_y': 100 * np.random.random(size = (8,))})
df.timestamp = pd.to_datetime(df.timestamp, format = '%Y-%m-%d %H:%M:%S')

# Shuffle the colours
num_colours = 20
colours = [colours[num_colours][(2 * i) + (0 if (2 * i) < num_colours else (-num_colours + 1))] for i in range(0, num_colours)]

# Get unique devices
unique_devices = df.device.unique()

# %% Time series plot
# Create figure object
p_ts = figure(
        title = 'Time series plot',
        plot_width = 1200,
        plot_height = 800,
        x_axis_type = 'datetime',
        toolbar_location = None
    )

# Add each scatter to chart keeping track of items for legend
legend_items = []

for i, device in enumerate(unique_devices):
    # Get mask
    mask = df.device == device
    
    for col_name, alpha in [('value_x', 1.0), ('value_y', 0.6)]:
        # Create source dictionary
        d_source = {
                'x': df.loc[mask, 'timestamp'].values,
                'y': df.loc[mask, col_name].values,
                'device': df.loc[mask, 'device'].values,
                'timestamp': df.loc[mask, 'timestamp'].values
            }
        
        # Build data source
        source = ColumnDataSource(d_source)
        
        # Create dictionary for passing to circle plot
        d_line = {'x': 'x', 'y': 'y', 'line_width': 3, 'color': colours[i], 'source': source, 'alpha': alpha, 'legend_label': f'{device} {col_name}'}
        
        # Plot results    
        l = p_ts.line(**d_line)
        
        # Add to legend
        legend_items.append((device, [l]))
        
# Now create an empty data source highlighted points
source = ColumnDataSource({'x': [], 'y': []})
d_circles = {'x': 'x', 'y': 'y', 'size': 8, 'line_color': 'black', 'fill_color': None, 'line_width': 2, 'source': source}
highlight_circles = p_ts.circle(**d_circles)

# Add y axis label
p_ts.yaxis.axis_label = 'Value (units)'

# Format x axis
p_ts.xaxis.formatter = DatetimeTickFormatter(
        hours = ['%Y-%m-%d %H:%M'],
        days = ['%Y-%m-%d %H:%M'],
        months = ['%Y-%m-%d'],
        years = ['%Y-%m-%d'])


# %% Scatter plot
# Create figure object
p_scatter = figure(
        title = 'Scatter Plot',
        plot_width = 600,
        plot_height = 800,
        toolbar_location = None
    )

# Add each scatter to chart keeping track of items for legend
legend_items = []
data_sources = []

for i, device in enumerate(unique_devices):
    # Get mask
    mask = df.device == device
    
    # Create source dictionary
    d_source = {
            'x': df.loc[mask, 'value_x'].values,
            'y': df.loc[mask, 'value_y'].values,
            'device': df.loc[mask, 'device'].values,
            'timestamp': df.loc[mask, 'timestamp'].values
        }
    
    # Build data source
    source = ColumnDataSource(d_source)
    
    # Create dictionary for passing to circle plot
    d_circle = {'x': 'x', 'y': 'y', 'size': 5, 'color': colours[i], 'source': source}
    
    # Plot results    
    c = p_scatter.circle(**d_circle)
    
    # Add to legend
    legend_items.append((device, [c]))
    
    # Add to data sources
    data_sources.append(c)

# Set up axis labels
p_scatter.xaxis.axis_label = 'Value for X'
p_scatter.yaxis.axis_label = 'Value for Y'

# Move legend
legend = Legend(items = legend_items, location = (0, 0))
p_scatter.add_layout(legend, 'above')

# Build Javascript callback
code = """
const indices = cb_data.index.indices;
const data = {'x': [], 'y': []};

for (var i = 0; i < indices.length; i++) {
    var idx = indices[i];
    
    var ts = cb_data.renderer.data_source.data.timestamp[idx];
    var x = cb_data.renderer.data_source.data.x[idx];
    var y = cb_data.renderer.data_source.data.y[idx];
    
    data['x'].push(ts);
    data['x'].push(ts);
    data['y'].push(x);
    data['y'].push(y);
    
    console.log("Timestamp is " + ts);
    console.log("X is " + x);
    console.log("Y is " + y);
    
    console.log({
        "circles": circles,
        "obj": cb_obj,
        "data": cb_data
    });
    
    console.log(data);
}

circles.data = data;
//circles.change.emit();

if (indices.length > 0) {
    console.log(circles);
    debugger;
}

"""

callback = CustomJS(args = {'circles': highlight_circles.data_source}, code = code)

# Add hover tool with callback
ht = HoverTool(
    show_arrow = False,
    line_policy = 'next',
    tooltips = [('Device', '@device'), ('Timestamp', '@timestamp{%Y-%m-%d %H:%M:%S}'), ('Value of X', '@x'), ('Value of Y', '@y')],
    formatters = {'@timestamp': 'datetime'},
    callback = callback)

# Add hovertool to chart
p_scatter.add_tools(ht)

# %% Plot chart
show(row(p_scatter, p_ts))

That’s because HoverTool calls its callback for each renderer.

You can try doing what HoverTool does internally - use the data_source.inspect signal and connect it to a callback that changes the data for the time series plot circles.

Thanks for your fast reply @p-himik!

The bad news is I don’t quite understand what you mean - could you elaborate more on both your points please?

Given that you’re familiar with JS, you can look into the inner workings of HoverTool if you want to understand how callback is called exactly. The code there is pretty straightforward.

Regarding the inspect signal - I just checked and yeah, it’s not as easy to use without writing even more JavaScript and getting deeper into how Bokeh works.

Alternatively, you can alter your callback code. The documentation doesn’t say so, but it also receives renderer in its cb_obj. You could use it in addition to either separating the time series plot circles data source or adding a column to that data source that says where the relevant point came from, so later you could either remove the data from the relevant data source or remove the relevant rows from a single data source.

@p-himik: Many thanks for the additional information! Sorry about my terse reply earlier, I had 30 seconds to type a reply before my kids self-destructed before dinner.

I have since taken your advice on board, and now I only remove entries from the list if they match the current “device” (series identifier). Here’s my final code for anyone trying this:

# Imports
import numpy as np
import pandas as pd

# Plotting
from bokeh.palettes import Category20 as colours
from bokeh.models import ColumnDataSource, Legend, HoverTool, CustomJS, DatetimeTickFormatter
from bokeh.plotting import figure, show
from bokeh.layouts import row

# Make data frame of test data
df = pd.DataFrame(data = {'device': ['abc'] * 3 + ['def'] * 5, 'timestamp': [f'2017-01-01 {i:02.0f}:00:00' for i in range(0, 8)], 'value_x': 100 * np.random.random(size = (8,)), 'value_y': 100 * np.random.random(size = (8,))})
df.timestamp = pd.to_datetime(df.timestamp, format = '%Y-%m-%d %H:%M:%S')

# Shuffle the colours
num_colours = 20
colours = [colours[num_colours][(2 * i) + (0 if (2 * i) < num_colours else (-num_colours + 1))] for i in range(0, num_colours)]

# Get unique devices
unique_devices = df.device.unique()

# %% Time series plot
# Create figure object
p_ts = figure(
        title = 'Time series plot',
        plot_width = 1200,
        plot_height = 800,
        x_axis_type = 'datetime',
        toolbar_location = None
    )

# Add each scatter to chart keeping track of items for legend
legend_items = []

for i, device in enumerate(unique_devices):
    # Get mask
    mask = df.device == device
    
    for col_name, alpha in [('value_x', 1.0), ('value_y', 0.6)]:
        # Create source dictionary
        d_source = {
                'x': df.loc[mask, 'timestamp'].values,
                'y': df.loc[mask, col_name].values,
                'device': df.loc[mask, 'device'].values,
                'timestamp': df.loc[mask, 'timestamp'].values
            }
        
        # Build data source
        source = ColumnDataSource(d_source)
        
        # Create dictionary for passing to circle plot
        d_line = {'x': 'x', 'y': 'y', 'line_width': 3, 'color': colours[i], 'source': source, 'alpha': alpha, 'legend_label': f'{device} {col_name}'}
        
        # Plot results    
        l = p_ts.line(**d_line)
        
        # Add to legend
        legend_items.append((device, [l]))
        
# Now create an empty data source highlighted points
source = ColumnDataSource({'x': [], 'y': [], 'device': []})
d_circles = {'x': 'x', 'y': 'y', 'size': 8, 'line_color': 'black', 'fill_color': None, 'line_width': 2, 'source': source}
highlight_circles = p_ts.circle(**d_circles)

# Add y axis label
p_ts.yaxis.axis_label = 'Value (units)'

# Format x axis
p_ts.xaxis.formatter = DatetimeTickFormatter(
        hours = ['%Y-%m-%d %H:%M'],
        days = ['%Y-%m-%d %H:%M'],
        months = ['%Y-%m-%d'],
        years = ['%Y-%m-%d'])


# %% Scatter plot
# Create figure object
p_scatter = figure(
        title = 'Scatter Plot',
        plot_width = 600,
        plot_height = 800,
        toolbar_location = None
    )

# Add each scatter to chart keeping track of items for legend
legend_items = []
data_sources = []

for i, device in enumerate(unique_devices):
    # Get mask
    mask = df.device == device
    
    # Create source dictionary
    d_source = {
            'x': df.loc[mask, 'value_x'].values,
            'y': df.loc[mask, 'value_y'].values,
            'device': df.loc[mask, 'device'].values,
            'timestamp': df.loc[mask, 'timestamp'].values
        }
    
    # Build data source
    source = ColumnDataSource(d_source)
    
    # Create dictionary for passing to circle plot
    d_circle = {'x': 'x', 'y': 'y', 'size': 5, 'color': colours[i], 'source': source}
    
    # Plot results    
    c = p_scatter.circle(**d_circle)
    
    # Add to legend
    legend_items.append((device, [c]))
    
    # Add to data sources
    data_sources.append(c)

# Set up axis labels
p_scatter.xaxis.axis_label = 'Value for X'
p_scatter.yaxis.axis_label = 'Value for Y'

# Move legend
legend = Legend(items = legend_items, location = (0, 0))
p_scatter.add_layout(legend, 'above')

# Build Javascript callback
code = """
const indices = cb_data.index.indices;
const data = {'x': [], 'y': [], 'device': []};
const device = cb_data.renderer.data_source.data.device[0];

// Remove old entries with the same device as this one
for (var i = 0; i < circles.data.device.length; i++) {
    let this_device = circles.data.device[i];
    
    if (!(this_device === device)) {
        data['x'].push(circles.data.x[i]);
        data['y'].push(circles.data.y[i]);
        data['device'].push(this_device);
    }
}

// Add new entries
for (var i = 0; i < indices.length; i++) {
    let idx = indices[i];
    
    let ts = cb_data.renderer.data_source.data.timestamp[idx];
    let x = cb_data.renderer.data_source.data.x[idx];
    let y = cb_data.renderer.data_source.data.y[idx];
    
    data['x'].push(ts);
    data['x'].push(ts);
    data['y'].push(x);
    data['y'].push(y);
    data['device'].push(device);
    data['device'].push(device);
}

circles.data = data;

"""

callback = CustomJS(args = {'circles': highlight_circles.data_source}, code = code)

# Add hover tool with callback
ht = HoverTool(
    show_arrow = False,
    line_policy = 'next',
    tooltips = [('Device', '@device'), ('Timestamp', '@timestamp{%Y-%m-%d %H:%M:%S}'), ('Value of X', '@x'), ('Value of Y', '@y')],
    formatters = {'@timestamp': 'datetime'},
    callback = callback)

# Add hovertool to chart
p_scatter.add_tools(ht)

# %% Plot chart
show(row(p_scatter, p_ts))
1 Like

Awesome! Glad you made it work.