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
figure
s and add Glyph
s 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 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 ColumnDataSource
s 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 CDSView
s 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
)
)