How do I link the CrossHairTool in bokeh over several plots?

后端 未结 5 1270
逝去的感伤
逝去的感伤 2020-12-30 07:34

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

相关标签:
5条回答
  • 2020-12-30 08:05

    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
    
    def add_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.

    0 讨论(0)
  • 2020-12-30 08:05

    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
    
    def add_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 in range(len(figs)-1):
            js_move += '\t\t\tother%d.spans.height.computed_location = cb_obj.sx\n' % i
        js_move += '}else{\n'
        for i in range(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 in enumerate(figs):
            fig.add_tools(crosses[i])
            args = {'fig': fig}
            k = 0
            for j in range(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))
    

    0 讨论(0)
  • 2020-12-30 08:06

    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 in range(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))
    
    0 讨论(0)
  • 2020-12-30 08:12

    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
    
    def addLinkedCrosshairs(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 in range(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:

    0 讨论(0)
  • 2020-12-30 08:12

    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
    
    def add_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))
    
    def to_seconds(date):
        return time.mktime(date.timetuple())
    
    def timeline_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
    
    def usage():
        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 in range(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:

    0 讨论(0)
提交回复
热议问题