Skip to content Skip to sidebar Skip to footer

How Do I Link The Crosshairtool In Bokeh Over Several Plots?

When moving the crosshair (dimensions=width) in one plot I want to see the same position in the other plot(s). My plots share the same x-axis. Here is the plot setup and an example

Solution 1:

Bokeh currently has no built in support for this. I figured out how to do this using javascript callbacks. The following function works for two vertically aligned plots on Bokeh 0.13:

from bokeh.models import CustomJS, CrosshairTool

defadd_vlinked_crosshairs(fig1, fig2):
    cross1 = CrosshairTool()
    cross2 = CrosshairTool()
    fig1.add_tools(cross1)
    fig2.add_tools(cross2)
    js_move = '''
        if(cb_obj.x >= fig.x_range.start && cb_obj.x <= fig.x_range.end &&
           cb_obj.y >= fig.y_range.start && cb_obj.y <= fig.y_range.end)
        {
            cross.spans.height.computed_location = cb_obj.sx
        }
        else
        {
            cross.spans.height.computed_location = null
        }
    '''
    js_leave = 'cross.spans.height.computed_location = null'
    args = {'cross': cross2, 'fig': fig1}
    fig1.js_on_event('mousemove', CustomJS(args=args, code=js_move))
    fig1.js_on_event('mouseleave', CustomJS(args=args, code=js_leave))
    args = {'cross': cross1, 'fig': fig2}
    fig2.js_on_event('mousemove', CustomJS(args=args, code=js_move))
    fig2.js_on_event('mouseleave', CustomJS(args=args, code=js_leave))

The idea is to add a mouse move callback to each plot which triggers the vertical part of the crosshair on the other plot to be drawn. This is done by updating the spans.height.computed_location member of the crosshair with the screen position provided by the mouse callback (cb_obj.sx).

The mouse move event fires on the entire area of the plot, including axes, borders etc. Checks are added to make sure the mouse is inside the data space (cb_obj.x and cb_obj.y are axis coordinates) and the line is removed if it is not. A mouseleave event was also added since a fast move outside of the plot may not fire an event on the border area.

This works if the plots are vertically aligned. For horizontal alignment (as per the OP) just change cross.spans.height.computed_location -> cross.spans.width.computed_location and cb_obj.sx -> cb_obj.sy.

This only works if the plots are the same size, further checks will be necessary if they are not.

Solution 2:

As of bokeh v2.2.1 the solution is simplified. I am not sure whether it was already possible like this in previous versions. Here an example for sharing crosshair for both dimensions between 9 plots in a gridplot in bokeh v2.2.1:

import numpy as np
from bokeh.plotting import figure, show
from bokeh.layouts import gridplot
from bokeh.models import CrosshairTool

plots = [figure() for i inrange(6)]
[plot.line(np.arange(10), np.random.random(10)) for plot in plots]
crosshair = CrosshairTool(dimensions="both")
for plot in plots:
    plot.add_tools(crosshair)

show(gridplot(children=[plot for plot in plots], ncols=3))

Solution 3:

More compact example for arbitrary number of plots and for both CrossHair dimensions (updated for Bokeh v1.0.4):

from bokeh.models import CustomJS, CrosshairTool
from bokeh.plotting import figure, show, curdoc
from bokeh.layouts import gridplot
import numpy as np

defaddLinkedCrosshairs(plots):
    js_move = '''   start = fig.x_range.start, end = fig.x_range.end
                    if(cb_obj.x>=start && cb_obj.x<=end && cb_obj.y>=start && cb_obj.y<=end)
                        { cross.spans.height.computed_location=cb_obj.sx }
                    else { cross.spans.height.computed_location = null }
                    if(cb_obj.y>=start && cb_obj.y<=end && cb_obj.x>=start && cb_obj.x<=end)
                        { cross.spans.width.computed_location=cb_obj.sy  }
                    else { cross.spans.width.computed_location=null }'''
    js_leave = '''cross.spans.height.computed_location=null; cross.spans.width.computed_location=null'''

    figures = plots[:]
    for plot in plots:
        crosshair = CrosshairTool(dimensions = 'both')
        plot.add_tools(crosshair)
        for figure in figures:
            if figure != plot:
                args = {'cross': crosshair, 'fig': figure}
                figure.js_on_event('mousemove', CustomJS(args = args, code = js_move))
                figure.js_on_event('mouseleave', CustomJS(args = args, code = js_leave))

plots = [figure(plot_width = 200, plot_height = 200, tools = '') for i inrange(9)]
[plot.line(np.arange(10), np.random.random(10)) for plot in plots]
addLinkedCrosshairs(plots)
show(gridplot(children = [plot for plot in plots], ncols = 3))

To reduce to just one dimension (vertical or horizontal) remove the corresponding "if / else" part of the callback

Result:

enter image description here

Solution 4:

Marged solution (updated for Bokeh v1.0.4)

from bokeh.layouts import gridplot
from bokeh.models import CustomJS, CrosshairTool
from bokeh.plotting import figure, ColumnDataSource, output_file, save, show
from bokeh.models import Span, CrosshairTool, HoverTool, ResetTool, PanTool, WheelZoomTool
from datetime import datetime
from datetime import timedelta
import numpy as np
import time

defadd_vlinked_crosshairs(fig1, fig2):
    js_move = '''if(cb_obj.x >= fig.x_range.start && cb_obj.x <= fig.x_range.end && cb_obj.y >= fig.y_range.start && cb_obj.y <= fig.y_range.end)
                    { cross.spans.height.computed_location = cb_obj.sx }
                 else 
                    { cross.spans.height.computed_location = null }'''
    js_leave = 'cross.spans.height.computed_location = null'

    cross1 = CrosshairTool()
    cross2 = CrosshairTool()
    fig1.add_tools(cross1)
    fig2.add_tools(cross2)
    args = {'cross': cross2, 'fig': fig1}
    fig1.js_on_event('mousemove', CustomJS(args = args, code = js_move))
    fig1.js_on_event('mouseleave', CustomJS(args = args, code = js_leave))
    args = {'cross': cross1, 'fig': fig2}
    fig2.js_on_event('mousemove', CustomJS(args = args, code = js_move))
    fig2.js_on_event('mouseleave', CustomJS(args = args, code = js_leave))

defto_seconds(date):
    return time.mktime(date.timetuple())

deftimeline_figure(title = None, x_range = None, y_range = None):

    TOOLS = [CrosshairTool(dimensions = 'height'), PanTool(dimensions = 'width'), HoverTool(tooltips = [("Date", "@t")]), WheelZoomTool(dimensions = 'width'), ResetTool()]

    fig = figure(width = 800, height = 250, title = title, x_axis_type = "datetime", x_range = x_range, y_range = y_range, tools = TOOLS)
    fig.outline_line_color = 'white'
    fig.xgrid.grid_line_color = None
    fig.ygrid.grid_line_color = None
    fig.yaxis.minor_tick_line_color = None

    year = 2016
    dec = Span(location = to_seconds(datetime(year - 1, 12, 1, 0, 0, 0)))
    jan = Span(location = to_seconds(datetime(year, 1, 1, 0, 0, 0)))
    feb = Span(location = to_seconds(datetime(year, 2, 1, 0, 0, 0)))
    mar = Span(location = to_seconds(datetime(year, 3, 1, 0, 0, 0)))
    apr = Span(location = to_seconds(datetime(year, 4, 1, 0, 0, 0)))
    may = Span(location = to_seconds(datetime(year, 5, 1, 0, 0, 0)))

    fig.renderers.extend([dec, jan, feb, mar, apr, may])

    return fig

defusage():
    output_file("test_linked_crosshair.html", mode = "cdn")

    d_start = datetime(2016, 6, 1)
    d_step = timedelta(days = 1)

    t = [d_start + (i * d_step) for i inrange(0, 12)]
    s1 = np.random. randint(2, 10, 12)
    s2 = np.random.randint(2, 10, 12)
    source = ColumnDataSource({'t': t, 's1': s1, 's2': s2})

    p1 = timeline_figure()
    p1.triangle(x = 't', y = 's1', source = source, size = 10, color = "blue")
    p2 = timeline_figure(x_range = p1.x_range)
    p2.square(x = 't', y = 's2', source = source, size = 10, color = "red")

    add_vlinked_crosshairs(p1, p2)

    p = gridplot([[p1], [p2]])
    show(p)

if __name__ == "__main__":
    usage()

Result:

enter image description here

Solution 5:

This answer is for the people who liked Graeme's solution just as I did, but need to apply it to more than two figures just as I did:

from bokeh.models import CustomJS, CrosshairTool

defadd_vlinked_crosshairs(figs):
    js_leave = ''
    js_move = 'if(cb_obj.x >= fig.x_range.start && cb_obj.x <= fig.x_range.end &&\n'
    js_move += 'cb_obj.y >= fig.y_range.start && cb_obj.y <= fig.y_range.end){\n'for i inrange(len(figs)-1):
        js_move += '\t\t\tother%d.spans.height.computed_location = cb_obj.sx\n' % i
    js_move += '}else{\n'for i inrange(len(figs)-1):
        js_move += '\t\t\tother%d.spans.height.computed_location = null\n' % i
        js_leave += '\t\t\tother%d.spans.height.computed_location = null\n' % i
    js_move += '}'
    crosses = [CrosshairTool() for fig in figs]
    for i, fig inenumerate(figs):
        fig.add_tools(crosses[i])
        args = {'fig': fig}
        k = 0for j inrange(len(figs)):
            if i != j:
                args['other%d'%k] = crosses[j]
                k += 1
        fig.js_on_event('mousemove', CustomJS(args=args, code=js_move))
        fig.js_on_event('mouseleave', CustomJS(args=args, code=js_leave))

crosshair on bchusdt plot

Post a Comment for "How Do I Link The Crosshairtool In Bokeh Over Several Plots?"