0

I have inherited some Python code that uses Bokeh from a developer who has left. I don’t fully understand what it is doing.

We create a figure that we use to create a timeline. x-axis is time. There are a number of events which are drawn as rectangles which appear in a number of rows. Rows can contain several different types of event. Position and width of event represent time and duration.

There can have more than 100,000 events. Because Bokeh becomes slow when trying to draw this many events we limit the x-range to reduce the number of events that have to be drawn. Users then have to pan to see the rest of the timeline. Sometimes we have to restrict the time range quite a lot.

What we are doing now means that users see full detail of all the events but with a limited time range so they don’t get to see the big picture over the whole time range.

We have a legend on the figure with checkboxes that allows users to hide some of the event types to reduce the number that are displayed.

Some users would like to be able to hide event types they are not interested in, reducing the total number of events, and then zoom out further than we currently allow them to see more of the time range and get to see the bigger picture.

Can anyone suggest an approach to achieve this?

One theoretical approach is when a checkbox is unchecked to hide a class of event there is a callback which calculates a new maximum time range based on the number of events now visible and dynamically updates the time range of the figure. Is this possible?

Here’s a picture to help. The time range is 264s but we’re restricting it to 36s to get decent performance. What looks like solid bars are actually 1000s of separate events. enter image description here

It's extremely difficult to provide code because the Bokeh calls are dotted throughout our code and I did not write it. This may or may not be helpful:

        x_range= Range1d(start=self._time_range_params.range_start, end=self._time_range_params.range_end,
                       bounds=(self._time_range_params.bound_lower, self._time_range_params.bound_upper),
                       min_interval=self._time_range_params.zoom_min_interval,
                       max_interval=self._time_range_params.zoom_max_interval)

        self._figure = figure(plot_width=self.DEFAULT_WIDTH, plot_height=plot_height, sizing_mode=self.SIZING_MODE,
                              y_range=y_range, x_range=x_range, name=timeline_name, lod_factor=self.LOD_FACTOR,
                              lod_threshold=self.LOD_THRESHOLD, lod_timeout=self.LOD_TIMEOUT,
                              tags=create_figure_tags(self._x_axis_data, bounds=x_range.bounds),
                              css_classes=[self.CSS_CLASS_NAME])

        for event_type in self._event_types:
            r_status, r_event = self._plot_event_type(status_filter, event_type)
            legend_items.append(LegendItem(label=event_type, renderers=[r_status, r_event]))
            status_renderers.append(r_status)
            event_renderers.append(r_event)

        # add toggle all option
        legend_items.append(self._create_toggle_all_legend_item([*status_renderers, *event_renderers]))

        self._add_legend(legend_items, label='All', name=f'{self._figure.name}Legend0',
                         title='Click to hide/show events', visible=True)

        # store the event renderers to allow hit testing e.g. for hover tool, to avoid duplicates from status renderers
        self._event_renderers = event_renderers

        range_filter = DynamicRangeFilter(self._figure, self._data_source, self._time_range_params,
                                          self._view_props.timestamp_format, TimelineItemHandler.PROP_COLUMNS, self._view_props.log_name,
                                          update_on_linked_selections=update_on_linked_selections)


def _plot_event_type(self, status_filter: CustomJSFilter, event_type: str) -> Tuple[Renderer, Renderer]:
    """Plot the statuses and events for a single event type on the timeline.
    Returns:
        tuple of [status_renderer, event_renderer]
    """

    # create filtered views to select events of just this type from the column data source
    # (filters are computed in the browser by Bokeh)
    event_filter = GroupFilter(column_name='EventType', group=event_type)

    # plot taller boxes in status colour underneath boxes in event colour
    # this creates a top-bottom border for events with the status colour when rendered

    # get events of just this type which have a known status
    event_status_view = CDSView(source=self._data_source, filters=[status_filter, event_filter])
    r_status = self._figure.rect(x='TimeMid', y='EventGroup', width='Duration',
                                 height=self.EVENT_STATUS_HEIGHT,
                                 line_color='StatusColor', fill_color='StatusColor', alpha=1,
                                 source=self._data_source, view=event_status_view, dilate=True)

    event_view = CDSView(source=self._data_source, filters=[event_filter])
    r_event = self._figure.rect(x='TimeMid', y='EventGroup', width='Duration', height=self.EVENT_HEIGHT,
                                line_color='EventColor', fill_color='EventColor', alpha=1,
                                source=self._data_source, view=event_view, dilate=True)

    return r_status, r_event

3
  • Tried to explain better. See section in italics. Commented Jun 30, 2023 at 9:19
  • This is definitely something you should be able to do in Bokeh. Please, can you provide a code sample and the data schema? Don't hesitate to have a look at these pages: - stackoverflow.com/help/how-to-ask - stackoverflow.com/help/minimal-reproducible-example Commented Jun 30, 2023 at 17:59
  • Added some code which may or may not be helpful Commented Jul 3, 2023 at 7:01

1 Answer 1

0

If I understand properly, you need to:

  • display / hide the events based on user selection in the legend. p.legend.click_policy = 'hide' is the option you'll need here.
  • update x and y axes scales depending on the data displayed. x_range=DataRange1d(only_visible=True) enables to update the x-axis depending on the data displayed.

Example (you can try it in a Jupyter Notebook)


import pandas as pd
from bokeh.io import show, output_notebook
from bokeh.models import ColumnDataSource, DataRange1d, BoxZoomTool, ResetTool, CDSView, GroupFilter
from bokeh.plotting import figure
from bokeh.layouts import column
import random
output_notebook()

def create_plot(doc):
    
    # Create fake data
    df = pd.DataFrame({
        'TimeMid': random.sample(range(2, 10), 4) + random.sample(range(7, 17), 4) + random.sample(range(12, 20), 4) + random.sample(range(1, 7), 4), 
        'EventGroup': ['event_1']*4 + ['event_2']*4 + ['event_3']*4 + ['event_4']*4,
        'color': ['blue']*4 + ['red']*4 + ['black']*4 + ['yellow']*4
    })
    
    event_types = list(set(df['EventGroup']))
    
    # Create column data source
    data_source = ColumnDataSource(data=df)

    # Store view for each event in a dict
    view_events = {
        'event_1': CDSView(source=data_source,
                           filters=[GroupFilter(column_name='EventGroup', group='event_1')]),
        'event_2': CDSView(source=data_source,
                           filters=[GroupFilter(column_name='EventGroup', group='event_2')]),
        'event_3': CDSView(source=data_source,
                           filters=[GroupFilter(column_name='EventGroup', group='event_3')]),
        'event_4': CDSView(source=data_source,
                           filters=[GroupFilter(column_name='EventGroup', group='event_4')])
    }

    # Create figure
    p = figure(title="Legend tap example", 
               x_range=DataRange1d(min(list(df['TimeMid']))-1, max(list(df['TimeMid']))+1, only_visible=True),
               y_range=event_types,
               tools = [BoxZoomTool(), ResetTool()],
               match_aspect = True)
    
    for event, view in view_events.items():
        p.rect(x='TimeMid', 
               y='EventGroup', 
               legend_label=f'{event}',
               width= 1,
               height=0.1, 
               fill_color='color', 
               source=data_source,
               view=view)

    # Hide each glyph by clicking on its legend
    p.legend.click_policy = 'hide'
    
    doc.add_root(p)

show(create_plot)

Note: I added a BoxZoomTool in the figure, so that users can zoom on the x-axis.

Sign up to request clarification or add additional context in comments.

4 Comments

Thanks. However we have a Legend rather than a CheckboxButtonGroup and I'm not sure how to get hold of the checkboxes within the legend to attach JS to them.
I updated the code. If you click on an event in the legend, the event will be hidden/displayed and the x scale will be updated accordingly.
Thanks Juliette. I think what I need is a callback that fires each time a checkbox is clicked in the legend to hide or show a type of event. I also need to change the max_interval (of the Range1D of the x-axis) in this callback.
I don't get what you want here. Did you try the code in a Jupyter notebook? each time a checkbox is clicked in the legend to hide or show a type of event ==> it's working already. I also need to change the max_interval (of the Range1D of the x-axis) in this callback. ==> the x-axis is updated depending on the events selected. You can try to select only one event to see how it works. I guess I'm missing something here but I don't know what, so I'm not able to help you more, I'm sorry!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.