Bokeh: Interactive plots in static HTML#

Welcome back, everyone! Before I get started, I want to let you know about an upcoming FREE seminar: “On the Spot, Live-Coded Data Visualizations,” where I’ll be live-coding data visualizations that YOU pick for me! You won’t want to miss it!

Last week, I shared a primer on Bokeh. This week, I wanted to take things up a notch and share some of the more powerful features Bokeh has beyond its core components. Sure, we can make figures and add Glyphs to them, but we can also make a completely responsive data visualization with just a few lines of JavaScript.

We’ll set up all of our pieces using the Bokeh Python API, and then add a bit of callback “glue” to appropriately trigger redrawing events.

If you saw my seminar “You Need to Try Bokeh,” then this data set will look quite familiar! We are working with our Star Trader data set to make a brief dashboard entirely in Bokeh that will be responsive, even when served without the aid of the Bokeh server. (If you’re not familiar with Star Trader, here’s some history on the game.)

Star Trader Data#

Let’s take a look at that data set. We’ll need to compute the aggregations we want to display. My end-goal is the ability to toggle through each player and see data based on their trades.

Since we won’t have the assistance of a Bokeh server, we will need to pre-compute all of the data needed by each of our views and then dynamically filter each of those queries based on the currently selected player.

from pandas import Categorical
from data import players, trades

trades = (
    trades.assign(
        direction=lambda d: Categorical.from_codes(
            d['volume'].lt(0).astype(int), categories=['bought', 'sold']
        ),
        credit_change=lambda d: d['price'] * -d['volume']
    )
    .pipe(lambda d:
        d.set_axis(d.index.set_levels(
            [f'Ship {s}' for s in d.index.levels[2]], level='ship'
        ))
    )
)

trades.head()
price volume direction credit_change
date player ship star asset
2020-01-01 Alice Ship 0 Sol Medicine 245.18 110 bought -26969.8
Bob Ship 2 Sol Medicine 248.40 -240 sold 59616.0
Charlie Ship 0 Sol Metals 51.20 -290 sold 14848.0
Ship 2 Sol Star Gems 9804.00 0 bought 0.0
Ship 3 Sol Equipment 148.39 -380 sold 56388.2
agg_trades = (
    trades.pivot_table(
        index='date', columns=['player', 'ship'], values='credit_change'
    )
    .cumsum()
    .ffill()
    .fillna(0)
    .unstack()
    .reset_index(name='credit')
)

agg_trades
/tmp/ipykernel_903994/3437608390.py:2: FutureWarning: The default value of observed=False is deprecated and will change to observed=True in a future version of pandas. Specify observed=False to silence this warning and retain the current behavior
  trades.pivot_table(
player ship date credit
0 Alice Ship 0 2020-01-01 -26969.8
1 Alice Ship 0 2020-01-02 -26969.8
2 Alice Ship 0 2020-01-03 -26969.8
3 Alice Ship 0 2020-01-04 -26969.8
4 Alice Ship 0 2020-01-05 -126546.4
... ... ... ... ...
9850 George Ship 2 2020-12-26 -48618.0
9851 George Ship 2 2020-12-27 -48618.0
9852 George Ship 2 2020-12-28 -48618.0
9853 George Ship 2 2020-12-29 -48618.0
9854 George Ship 2 2020-12-30 -48618.0

9855 rows × 4 columns

agg_assets = (
    trades.pivot_table(index=['date'], columns=['player', 'asset'], values='volume')
    .ffill()
    .abs()
    .stack('player')
    .fillna(0)
    .reset_index()
)

agg_assets
/tmp/ipykernel_903994/1426815411.py:2: FutureWarning: The default value of observed=False is deprecated and will change to observed=True in a future version of pandas. Specify observed=False to silence this warning and retain the current behavior
  trades.pivot_table(index=['date'], columns=['player', 'asset'], values='volume')
/tmp/ipykernel_903994/1426815411.py:5: FutureWarning: The previous implementation of stack is deprecated and will be removed in a future version of pandas. See the What's New notes for pandas 2.1.0 for details. Specify future_stack=True to adopt the new implementation and silence this warning.
  .stack('player')
asset date player Equipment Medicine Metals Software Star Gems Uranium
0 2020-01-01 Alice 0.0 110.0 0.0 0.0 0.0 0.0
1 2020-01-01 Bob 0.0 240.0 0.0 0.0 0.0 0.0
2 2020-01-01 Charlie 380.0 0.0 290.0 0.0 0.0 0.0
3 2020-01-01 Evan 0.0 0.0 0.0 0.0 0.0 20.0
4 2020-01-01 Frankie 0.0 0.0 0.0 0.0 0.0 0.0
... ... ... ... ... ... ... ... ...
2549 2020-12-30 Charlie 350.0 170.0 1690.0 1500.0 0.0 100.0
2550 2020-12-30 Dana 5.0 330.0 1780.0 40.0 0.0 80.0
2551 2020-12-30 Evan 150.0 20.0 180.0 990.0 0.0 20.0
2552 2020-12-30 Frankie 100.0 0.0 1250.0 1930.0 10.0 0.0
2553 2020-12-30 George 10.0 10.0 10.0 10.0 10.0 10.0

2554 rows × 8 columns

Plotting Prep#

With our aggregations finalized, we can start creating our visualizations. Prototyping in Bokeh in a Jupyter notebook is quite easy: you’ll need to pip or conda installjupyter_bokeh extension, and then you’ll need to call the output_notebook function. This enables Jupyter to embed your Bokeh figures inline.

from bokeh.io import output_notebook
output_notebook()
Loading BokehJS ...
from bokeh.io import show
from bokeh.plotting import figure, ColumnDataSource
from bokeh.models import (
    Select, Button,
    CDSView, GroupFilter,
    Legend, LegendItem,
    NumeralTickFormatter,
    Slope,
    RangeTool, HoverTool,
    Div,
    CustomJS
)

from bokeh.palettes import Dark2_6, Category10_10

levels = {
    name: levels 
    for name, levels in zip(trades.index.names, trades.index.levels)
}

sources = {
    'credit': ColumnDataSource(agg_trades),
    'asset': ColumnDataSource(agg_assets)
}

levels tracks all unique values in each level of our index, and sources tracks the ColumnDataSources that Bokeh needs to easily synchronize data between the Bokeh server and the JavaScript front-end. While we won’t be leveraging bi-directional communication in this example, we will need to load all of our data onto the front-end in order to apply dynamic filtering via JavaScript.

Likewise, sources tracks each of our aggregated data. While it’s not necessary to structure your code this way, I find it convenient when working with multiple data sets.

Tracking Credits by Trade Date#

Let’s start with our first plot: exchanged credits by trade. This will be a fairly straightforward plot of 'credit' by 'date' from the agg_trades data. To perform filtering purely in the front-end, we are going to need to apply custom CDSViews with a GroupFilter.

We’ll also manually create a Legend so that we can position it outside the plotting area as well.

figures = {
    'credit': figure(
        title='Trades by Ship',
        sizing_mode='stretch_width', height=400, 
        x_range=[levels['date'].min(), levels['date'].max()],
        tools='xbox_zoom,reset', x_axis_type='datetime'
    ),
}

credit_legend = Legend(
    orientation='horizontal', click_policy='mute', location='right'
)

all_views = []
player_filter = GroupFilter(column_name='player', group=levels['player'][0])
for ship, color in zip(levels['ship'], Category10_10):
    view = CDSView(
        filter=player_filter & GroupFilter(column_name='ship', group=ship)
    )
    all_views.append(view)

    renderer = figures['credit'].step(
        x='date', y='credit',
        source=sources['credit'],
        line_width=2,
        view=view,
        color=color,
    )
    credit_legend.items.append(
        LegendItem(renderers=[renderer], label=ship)
    )
figures['credit'].add_layout(credit_legend, 'above')

figures['credit'].yaxis.formatter = NumeralTickFormatter(format='0a')
figures['credit'].yaxis.ticker.num_minor_ticks = 0
figures['credit'].ygrid.visible = False
figures['credit'].add_layout(Slope(gradient=0, y_intercept=0, line_dash='dashed'))
figures['credit'].title.text_font_size = '18pt'

show(figures['credit'])

Tracking Total Assets Exchanged#

Next up, we want to track the total volume of assets that have been exchanged as part of each trade. To visualize this, we can use a stacked area chart so we can eyeball the contribution of each asset to the total volume traded. We’ll use some of the same tricks as above in order to dynamically apply our filtering.

figures['asset'] = figure(
    title='Total Assets Exchanged',
    sizing_mode='stretch_width', height=int(figures['credit'].height // 1.5),
    x_range=figures['credit'].x_range, x_axis_type='datetime',
    tools='xbox_zoom,reset'
)

varea_renderers = figures['asset'].varea_stack(
    stackers=sorted(levels['asset']), x='date',
    source=sources['asset'],
    color=Dark2_6,
    alpha=.8,
    view=CDSView(filter=player_filter)
)
all_views.append(varea_renderers[0].view)
asset_legend = Legend(
    items=[
        LegendItem(renderers=[r], label=l)
        for r, l in zip(varea_renderers, levels['asset'])
    ],
    click_policy='mute',
    orientation='horizontal'
)
figures['asset'].add_layout(asset_legend, 'above')

figures['asset'].y_range.start = 0
figures['asset'].yaxis.ticker.num_minor_ticks = 0
figures['asset'].yaxis.formatter = NumeralTickFormatter(format='0a')
figures['asset'].xgrid.visible = False
figures['asset'].title.text_font_size = '18pt'

show(figures['asset'])

Adding Interactivity#

Let’s add some interactivity to our plots! Since this will be served on a static webpage, we’ll need to handle our interactions in JavaScript. Thankfully, we can rely on Bokeh to deal with most of the complexity for us—all we need to do is change what we are filtering on (player) and notify Bokeh that our CDSView has changed and to redraw the plot!

# RangeTool operates on an entire plot, we’ll create a copy the trades
#   plot to work with

figures['range'] = figure(
    height=figures['credit'].height // 3,
    x_range=[levels['date'].min(), levels['date'].max()],
    x_axis_type='datetime', toolbar_location=None,
    title='Date Zoom', sizing_mode='inherit',
)

# add all glyphs from the trades by credit plot to this new plot
for rend in figures['credit'].renderers:
    new_rend = figures['range'].add_glyph(rend.data_source, rend.glyph)
    new_rend.view = rend.view

figures['range'].yaxis.visible = False
figures['range'].ygrid.visible = False

range_tool = RangeTool(x_range=figures['credit'].x_range)
figures['range'].add_tools(range_tool)


# The RangeTool has no way to reset itself, so we can create a button
#   to do that for us
reset = Button(label='Reset Date Range', button_type='warning', sizing_mode='inherit')
reset.js_on_click(CustomJS(
    args=dict(x_range=range_tool.x_range, min_date=range_tool.x_range.start, max_date=range_tool.x_range.end), 
    code='''
    x_range.start = min_date;
    x_range.end = max_date;
'''))


# Create a dropdown widget and add a callback that executes some javascript
select = Select(
    value=levels['player'][0],
    options=sorted(levels['player']), 
    sizing_mode='inherit'
)
select.js_on_change(
    'value',
    CustomJS(
        args=dict(player_filter=player_filter, all_views=all_views),
        code='''
        player_filter.group = cb_obj.value
        for (let i = 0; i < all_views.length; i++) {
          all_views[i].properties.filter.change.emit()
        }
        '''
    )
)

# Finally, lay out all of our plots and widgets!
from bokeh.layouts import column, Spacer, row, gridplot
show(
    column(
        Div(text='<h2>Startrader Player Viewer</h2>'),
        select,
        reset,
        figures['range'],
        Spacer(height=3, background='lightgray', margin=20), 
        gridplot([
            [figures['credit']],
            [figures['asset']],
        ], 
            toolbar_location='above',
            sizing_mode='stretch_width',
        ),
        background='white',
        sizing_mode='stretch_width',
        max_width=700
    )
)