Getting selected elements by a time series range. Bokeh

As a newcomer to Bokeh, i would like to know what the events have to be handled to figure out the selected elements on a timeseris plot by a range tool to process them further on a server side and what an object receives those events, Plot or RangeTool.

In the case of using a range tool, elements aren't selected but the start and end of the range dates are, therefore, a final array of them is extracted manually from a data source.

The Bokeh documentation doesn't answer the questions, so a straight way to get to know it is examining all the events of both objects.

Prepare a test stand

First of all, the test stand is run as a server to run python callbacks for handling events. The example is taken as a basis to extend.

stand.py

from functools import partial

import numpy as np
import tornado.ioloop
import bokeh.events
from bokeh.models import ColumnDataSource, RangeTool
from bokeh.plotting import figure, show
from bokeh.sampledata.stocks import AAPL
from bokeh.layouts import column
from bokeh.application.handlers.function import FunctionHandler
from bokeh.application.application import Application
from bokeh.server.server import Server


def make_models():
    dates = np.array(AAPL['date'], dtype=np.datetime64)
    source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))

    data_plot = figure(height=300, width=800, tools="xpan", toolbar_location=None,
               x_axis_type="datetime", x_axis_location="above",
               background_fill_color="#efefef", x_range=(dates[1500], dates[2500]))

    data_plot.line('date', 'close', source=source)
    data_plot.yaxis.axis_label = 'Price'

    range_plot = figure(title="Drag the middle and edges of the selection box to change the range above",
                    height=130, width=800, y_range=data_plot.y_range,
                    x_axis_type="datetime", y_axis_type=None,
                    tools="", toolbar_location=None, background_fill_color="#efefef")

    range_tool = RangeTool(x_range=data_plot.x_range)
    range_tool.overlay.fill_color = "navy"
    range_tool.overlay.fill_alpha = 0.2

    range_plot.line('date', 'close', source=source)
    range_plot.ygrid.grid_line_color = None
    range_plot.add_tools(range_tool)
    range_plot.toolbar.active_multi = range_tool

    return data_plot, range_plot, range_tool


def make_layout():
    data_plot, range_plot, range_tool = make_models()
    layout = column(data_plot, range_plot)
    return layout


def make_doc(doc):
    l = make_layout()
    doc.add_root(l)
    return doc


app = Application(FunctionHandler(make_doc))
srv = Server({'/': app}, io_loop=tornado.ioloop.IOLoop.current())
srv.run_until_shutdown()

Print out events data

Objects range_plot, range_tool are the targets to examine. Here is a function to assign a callback to the each event of both of them both. A callback prints event data as is to guess what the specific types containing a selected range we are looking for.

Plot events

Plot events are assigned by the on_event method, it tracks many UI element interations in comparison to on_change, which tracks the object attributes changes.

During running, a few of the events riase an execption an have to be excluded from a callback list:

AttributeError: type object 'DocumentEvent' has no attribute 'event_name'

Define a callback and assing it to:

def assign_callbacks(plot):

    def _print_out_callback(event_name, *args):
        print('Event: ', event_name, 'Data: ', args)

    event_names = list(bokeh.events.__all__)
    for excluded in ('DocumentEvent', 'Event', 'ModelEvent', 'PlotEvent', 'PointEvent'):
        event_names.remove(excluded)

    for event_name in event_names:
        event = getattr(bokeh.events, event_name)
        plot.on_event(event, partial(_print_out_callback, event_name))
        
 
def make_layout():
    data_plot, range_plot, range_tool = make_models()
    
    assign_callbacks(range_plot)
    layout = column(data_plot, range_plot)
    return layout

All the time the following events are emitted in plenty and should be ignored, we often move a mouse cursor:

Event:  MouseEnter Data:  (<bokeh.events.MouseEnter object at 0x7fc53a492a60>,)
Event:  MouseMove Data:  (<bokeh.events.MouseMove object at 0x7fc53a492c70>,)
Event:  MouseMove Data:  (<bokeh.events.MouseMove object at 0x7fc53a492400>,)
Event:  MouseMove Data:  (<bokeh.events.MouseMove object at 0x7fc53a492b20>,)

While moving a UI selection element to the left on the range_plot the follow events happen:

Event:  Press Data:  (<bokeh.events.Press object at 0x7fc53a492970>,)
Event:  PanStart Data:  (<bokeh.events.PanStart object at 0x7fc53a492730>,)
Event:  Pan Data:  (<bokeh.events.Pan object at 0x7fc53a492c10>,)
...
Event:  Pan Data:  (<bokeh.events.Pan object at 0x7fc53a4927f0>,)
Event:  RangesUpdate Data:  (<bokeh.events.RangesUpdate object at 0x7fc53a492400>,)
Event:  PanEnd Data:  (<bokeh.events.PanEnd object at 0x7fc53a492d90>,)

Ok, the output is bringing us to the idea that the events Press, Pan, PanStart, PanEnd, RangesUpdate can contain the targets. This list i would reduce to three of them PanStart, PanEnd, RangesUpdate due to their name semantic fits more to obtaining the range's start and end dates. As specfific events are found, the callback can be extended by printing their attributes:

def _print_out_callback(event_name, *args):
        if event_name in {'PanStart', 'PanEnd'}:
            event = args[0]
            print('Event: ', event_name, f'Data: {event.sx}, {event.sy}, {event.x}, {event.y}')
        elif event_name == 'RangesUpdate':
            event = args[0]
            print('Event: ', event_name, f'Data: {event.x0}, {event.y0}, {event.x1}, {event.y1}')
        else:
            print('Event: ', event_name, 'Data: ', args)

And output looks now:

Event:  PanStart Data: x=1199109699452.7036, sx=494.0841155052185, y=339.97113540536384, sy=68.16104125976562
Event:  RangesUpdate Data: x0=931357440000, x1=1382607360000, y0=-27.589000000000055, y1=719.729
Event:  PanEnd Data: x=1259761071764.9924, sx=593.545663356781, y=339.97113540536384, sy=68.16104125976562

PanStart.x, PanEnd.x timestamps look accurate in comparison to RangeUpdate.x0, RangeUpdate.x1.

Tool events

range_tool object is not a subclass of the Plot model, but Tool subclass, so on_change handler is assigned to it. Targets now are two object attributes range_tool.x_range.start, range_tool.x_range.end on x_range object of class Range1d .

def on_change_callback(attr, old, new):
    print(f'Attr: {attr}, old={old}, new={new}')
          

def make_layout():
    data_plot, range_plot, range_tool = make_models()

    # assign_callbacks(range_plot)
    print('Initital values.', 'start=', range_tool.x_range.start, 'end=', range_tool.x_range.end)
    range_tool.x_range.on_change('start', on_change_callback)
    range_tool.x_range.on_change('end', on_change_callback)
    layout = column(data_plot, range_plot)
    return layout
Initital values. start= 2006-02-17 end= 2010-02-09
Attr: start, old=2006-02-17, new=1146842169081.081
Attr: end, old=2010-02-09, new=1272381369081.081
...
Attr: start, old=1204772902054.0552, new=1205382699243.2444
Attr: end, old=1330312102054.054, new=1330921899243.2432

Check the timestamps whether they match the selection range position on the picture.

In [1]: from datetime import datetime

In [2]: datetime.utcfromtimestamp(1205382699243/1000)
Out[2]: datetime.datetime(2008, 3, 13, 4, 31, 39, 243000)

In [3]: datetime.utcfromtimestamp(1330921899243/1000)
Out[3]: datetime.datetime(2012, 3, 5, 4, 31, 39, 243000)

Final selection computation

Summing up the results of the above exploration, a final selection is easily found by handling the event PanEnd and converting timestamp attributes of the range_tool.x_range object to datetime.

from datetime import datetime


def pan_end_callback(selected_range, event):
    start= datetime.utcfromtimestamp(selected_range.start/1000)
    end = datetime.utcfromtimestamp(selected_range.end/1000)
    print(f'Attrs on PanEnd: start={start}, end={end}')
    
    
def make_layout():
    data_plot, range_plot, range_tool = make_models()

    range_plot.on_event(bokeh.events.PanEnd, partial(pan_end_callback, range_tool.x_range))
    layout = column(data_plot, range_plot)
    return layout    

Sample output:

Attrs on PanEnd: start=2007-09-11 16:26:12.324324, end=2011-09-03 16:26:12.324324

Selected elemets

The complete stand.py test stand script.

With the values returned by the pan_end_callback the data source source can be filtered out to extract elements for further server side processing.

In [1]: date_val = zip(source.data['date'], source.data['close'])
In [2]: date_val_sorted = sorted(date_val, key=lambda pair: pair[0])
In [3]: date_val_filtered = filter(lambda pair: datetime(2008, 3, 13, 4, 31, 39, 243000).date() 
                                                <= pair[0] <= 
                                                datetime(2012, 3, 5, 4, 31, 39, 243000).date(), 
                                    date_val_sorted)