Related
When you hover over the legend, all lines except the corresponding line will be hidden. How to achieve this via bokeh, python or javascript. I have no idea what to do to achieve this function. It would be great if we could provide a simple example with three lines.Thanks for your help.My code example is as follows:
import bokeh.palettes as bp
from bokeh.plotting import figure, output_file, show, ColumnDataSource
from bokeh.models import LinearAxis, Range1d, NumeralTickFormatter, Legend
from bokeh.layouts import column
import numpy as np
if __name__ == '__main__':
num = 5
color_list2 = bp.magma(num)
color_list1 = bp.viridis(num)
plotTools = 'box_zoom, wheel_zoom, pan, tap, crosshair, hover, reset, save'
p = figure(plot_width=800, plot_height=400, x_range=(0, 1000), y_range=(-2.5, -1.5),
tools=plotTools, toolbar_location='right', active_scroll='wheel_zoom', )
p.title.text = 'Hover and Hide'
items_c1 = []
i = 0
pictures = []
labels = ['a', 'b', 'c', 'd', 'e']
for label in labels:
n = np.random.randint(low=3, high=6)
xs = np.random.random(n) * 1000
y1s = np.random.random(n) - 2.5
temp_line = p.line(xs, y1s, line_width=2, color=color_list1[i % num],
alpha=0.3, hover_color='red', hover_alpha=0.9) # , legend_label=label
items_c1.append((label + '_BER', [temp_line]))
i = i + 1
if i % num == 0:
legend_1 = Legend(items=items_c1)
p.add_layout(legend_1, 'left')
p.xaxis.axis_label = 'run_time'
p.yaxis[0].axis_label = 'BER'
p.legend[0].orientation = 'vertical'
p.legend[0].location = 'bottom_center'
p.legend[0].click_policy = 'hide'
pictures.append(p)
p = figure(plot_width=800, plot_height=400, x_range=(0, 1000), y_range=(-2.5, -1.5),
tools=plotTools, toolbar_location='right', active_scroll='wheel_zoom', )
items_c1 = []
file = "test_ask_5"
file_path = file + '.html'
output_file(file_path)
show(column(pictures))
The solution below hides all lines but the one that is being clicked (not hovered). This code works for Bokeh v1.3.4
import numpy as np
from bokeh.plotting import figure, show
from bokeh.models import CustomJS
colors = ['orange', 'cyan', 'lightgreen']
p = figure()
lines = [p.line(np.arange(10), np.random.random(10), line_color = colors[i], line_width = 3, legend=colors[i], name=colors[i]) for i in range(3)]
code = '''if(Bokeh != 'undefined' && (Bokeh.active_line === 'undefined' || Bokeh.active_line == null))
{
Bokeh.active_line = cb_obj.name;
}'''
[line.js_on_change('visible', CustomJS(code = code)) for line in lines]
code = ''' for(i = 0; i < lines.length; i++) {
if (lines[i].name == Bokeh.active_line) {
lines[i].visible = true
}
else {
lines[i].visible = false
}
}
Bokeh.active_line = null'''
p.js_on_event('tap', CustomJS(args = {'lines' : lines}, code = code))
code = ''' for(i = 0; i < lines.length; i++) {
lines[i].visible = true
}
Bokeh.active_line = null'''
p.js_on_event('reset', CustomJS(args = dict(lines = lines), code = code))
p.legend.click_policy = 'hide'
show(p)
The first callback is applied to all glyph renderers (lines) and is being triggered when the line must be hidden, that is when a user clicks a legend item. The callback just sets the global variable Bokeh.active_line which remembers the renderer (line) name, e.g. "orange" or "cyan"
The second callback is attached to the plot canvas and is triggered every time the user clicks anywhere on the plot. What is basically does is inverting the glyphs (lines) visibility. It only shows the line specified by
Bokeh.active_line
The third callback is attached to the plot and is triggered when user clicks on the reset tool in the toolbar. It restores visibility of all lines.
Some context:
I was looking into the vispy module to plot in realtime (or as close as possible to) data coming from an instrument. My attempt follow.
from vispy.plot import Fig
from vispy import app,scene
from vispy.visuals import TextVisual
import numpy as np
import Queue
FONT_SIZE = 14
MIN = 0
MAX = 1.1
w_size = 100
N = 5000
M = 2500
color_map = 'cubehelix'
q_size = 1000
Nb = 5
#generate (empty) initial data to fill the plot
data = np.zeros(N*M)
data = np.reshape(data, (N,M))
#setup the plot
fig = Fig(show = False,size = (16*w_size,9*w_size),bgcolor='black')
fig.title = 'my plot'
main_plot = fig[0,0].image(data = data,fg_color='w',cmap=color_map,clim=(MIN,MAX))
fig[0,0].view.camera.aspect = N/float(M) * 16./9.
title = scene.Label("someoutput", font_size=FONT_SIZE, color = 'w')
fig[0,0].grid.add_widget(title, row=0, col=4)
fig[0,0].grid[2,4].border_color = 'black'
fig[0,0].grid[2,4].bgcolor = 'black'
xlabel_title = scene.Label("x_axis [unit]", font_size=FONT_SIZE, color = 'w')
fig[0,0].grid.add_widget(xlabel_title, row=4, col=4)
ylabel_title = scene.Label("y_axis [unit]", font_size=FONT_SIZE,rotation=-90, color='w')
fig[0,0].grid.add_widget(ylabel_title, row=2, col=2)
scale = scene.ColorBarWidget(orientation='left',
cmap=color_map,
label='Some value',
clim=(MIN,MAX),
border_color = 'w',
border_width = 1,
label_color = 'w'
)
fig[0,0].grid.add_widget(scale, row=2, col=6)
fig[0,0].cbar_right.width_max = \
fig[0,0].cbar_right.width_min = 50
#fill a queue so to excude the generation time from the plotting time
q = Queue.Queue()
for i in range(q_size):
new_data = (np.abs(0.5*np.random.randn(Nb*M)[:])).astype('float32')
new_data = np.reshape(new_data, (Nb,M))
q.put(new_data[:])
#update function
def update(ev):
global main_plot, q, data, Nb,M,fig,index
#acquire
new_data = q.get()
#roll the plot data
data[Nb:, :] = data[:-Nb, :]
data[:Nb,:] = new_data
#recycle the new data
q.put(new_data)
#update the plot
main_plot.set_data(data)
main_plot.update()
# setup timer
interv = 0.01
timer = app.Timer(interval = interv)
timer.connect(update)
timer.start(interval = interv)
if __name__ == '__main__':
fig.show(run=True)
app.run()
This code currently works but it's much slower than the data rate. In the vispy gallery, as well as in some examples, I saw much more points being plotted and updated. I think that the main problem is that I completely set each time all the data of the plot instead of shifting them and inserting new points.
I also had a look at this example:
https://github.com/vispy/vispy/blob/master/examples/demo/scene/oscilloscope.py
However I don't know how to generalize the update function that rolls the data (I have no knowledge of OpenGL) and I cannot use the example as is because I need a quantitative color scale (that seems well implemented in vispy.plot).
The question:
Is there a way to write a function that rolls the data of a plot generated with the vispy.plot class?
Thanks.
I'm trying to create a bokeh plot of the US States, and color each of the state according to some data. Now using this tutorial I managed to create this, but I also want to enhance it, and add a slider to it, to change the values displayed. For example like displaying separate years.
With the help of this tutorial, I managed to add the slider, and the underlying data does change, according to the hover text, but the colors aren't recalculated, and so the visual representation does not match the values.
This is the code I've used, from a Jupyter notebook, so anybody who wants to try can reproduce
from bokeh.io import show, output_notebook
from bokeh.models import (
ColumnDataSource,
HoverTool,
LogColorMapper,
Range1d, CustomJS, Slider
)
from bokeh.palettes import Inferno256 as palette
from bokeh.plotting import figure
from bokeh.layouts import row, widgetbox
from bokeh.sampledata.us_counties import data as counties
from bokeh.sampledata.us_states import data as states
from bokeh.sampledata.unemployment import data as unemployment
import pandas as pd
import random
output_notebook()
palette.reverse()
states_accumulated ={}
available_state_codes = states.keys()
for key, value in counties.items():
state_name = value["state"].upper()
if state_name in states.keys() and "number" not in states[state_name]:
states[state_name]["number"] = key[0]
for key,state in states.items():
state["code"] = key
state_list = []
for key,state in states.items():
state_list.append(state)
unemployment_transf = []
for key,value in unemployment.items():
unemployment_transf.append({
"State":key[0],
"County":key[1],
"Value":value
})
unemp_df = pd.DataFrame(unemployment_transf)
unemp_sum = unemp_df.groupby("State").mean()["Value"]
unemp_sum = unemp_sum.sort_index()
unemp_sum_flat = {key:value for key, value in unemp_sum.items()}
for state in state_list:
state["value"] = unemp_sum_flat[state["number"]]
state_df = pd.DataFrame(state_list)
color_mapper = LogColorMapper(palette=palette)
state_xy = (list(state_df["lons"].values),list(state_df["lats"].values))
max_x = max([max(l) for l in state_xy[0]])
max_y = max([max(l) for l in state_xy[1]])
min_x = min([min(l) for l in state_xy[0]])
min_y = min([min(l) for l in state_xy[1]])
data=dict(
x=state_xy[0],
y=state_xy[1],
name=list(state_df["name"].values),
used = list(state_df["value"].values)
)
data['1999'] = list(state_df["value"].values)
data['2000'] = [random.randrange(0,10) for i in range(len(state_xy[0]))]
source = ColumnDataSource(data)
TOOLS = "pan,wheel_zoom,reset,hover,save"
p = figure(
title="States", tools=TOOLS,
x_axis_location=None, y_axis_location=None
)
p.width=450
p.height = 450
p.x_range= Range1d(-170,-60)
p.y_range = Range1d(min_y-10,max_y+10)
p.grid.grid_line_color = None
renderer = p.patches('x', 'y', source=source,
fill_color={'field': 'used', 'transform': color_mapper},
fill_alpha=0.7, line_color="white", line_width=0.5)
hover = p.select_one(HoverTool)
hover.point_policy = "follow_mouse"
hover.tooltips = [
("Name", "#name"),
("Unemployment rate)", "#used%"),
("(Long, Lat)", "($x, $y)"),
]
callback = CustomJS(args=dict(source=source,plot=p,color_mapper = color_mapper,renderer = renderer), code="""
var data = source.data;
var year = year.value;
used = data['used']
should_be = data[String(year)]
for (i = 0; i < should_be.length; i++) {
used[i] = should_be[i];
}
""")
year_slider = Slider(start=1999, end=2000, value=1999, step=1,
title="year", callback=callback)
callback.args["year"] = year_slider
layout = row(
p,
widgetbox(year_slider),
)
show(layout)
Sample images of the plot:
What I would like to accomplish, is that when I change the slider, the colors on the plot should change. Now I think the JS callback should call some kind of redraw or recalculate, but I haven't found any documentation about it. Is there a way to do this?
append source.change.emit() to the Javascipt code to trigger the change event.
Appending source.trigger("change"); to the CustomJS seems to solve the problem, now as the slider changes, the colors change.
I am trying to produce a dashboard like interactions for my bar chart using callback function without using bokeh serve functionality. Ultimately, I would like to be able to change the plot if any of the two drop-down menus is changed. So far this only works when threshold value is hard-coded. I only know how to extract cb_obj value but not from dropdown that is not actually called. I have looked at this and this answer to formulate first attempt.
Here is my code:
from bokeh.io import show, output_notebook, output_file
from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure
from bokeh.transform import factor_cmap
from bokeh.models import CustomJS, ColumnDataSource, Slider, Select
from bokeh.layouts import column
import numpy as np
import pandas as pd
def generate_data(factor=10):
rawdata = pd.DataFrame(np.random.rand(10,4)*factor, columns = ["A","B","C","D"])
idx = pd.MultiIndex.from_product([["Exp "+str(i) for i in range(5)],
[20,999]],names=["Experiment","Threshold"])
rawdata.index = idx
return rawdata.reset_index()
# Generate data
output_notebook()
count_data = generate_data()
error_data = generate_data(factor=2)
groups = ["A","B","C","D"]
initial_counts = count_data[(count_data.Experiment == "Exp 0")
& (count_data.Threshold == 20)][["A","B","C","D"]].values[0]
initial_errors = error_data[(error_data.Experiment == "Exp 0")
& (error_data.Threshold == 20)][["A","B","C","D"]].values[0]
# Create primary sources of data
count_source = ColumnDataSource(data=count_data)
error_source = ColumnDataSource(data=error_data)
# Create plotting source of data
source = ColumnDataSource(data=dict(groups=groups, counts=initial_counts,
upper=initial_counts+initial_errors,
lower=initial_counts-initial_errors))
# Bar chart and figure
p = figure(x_range=groups, plot_height=350, toolbar_location=None, title="Values", y_range=(0,20))
p.vbar(x='groups', top='counts', width=0.9, source=source, legend="groups",
line_color='white', fill_color=factor_cmap('groups', palette=["#962980","#295f96","#29966c","#968529"],
factors=groups))
# Error bars
p.add_layout(
Whisker(source=source, base="groups", upper="upper", lower="lower", level="overlay")
)
def callback(source=source, count_source = count_source, error_source=error_source, window=None):
def slicer(data_source, experiment, threshold, dummy_col, columns):
""" Helper function to enable lookup of data."""
count = 0
for row in data_source[dummy_col]:
if (data_source["Experiment"][count] == experiment) & (data_source["Threshold"][count] == threshold):
result = [data_source[col][count] for col in columns]
count+=1
return result
# Initialise data sources
data = source.data
count_data = count_source.data
error_data = error_source.data
# Initialise values
experiment = cb_obj.value
threshold = 20
counts, upper, lower = data["counts"], data["upper"], data["lower"]
tempdata = slicer(count_data, experiment, threshold,"Experiment", ["A","B","C","D"])
temperror = slicer(error_data, experiment, threshold,"Experiment", ["A","B","C","D"])
# Select values and emit changes
for i in range(len(counts)):
counts[i] = tempdata[i]
for i in range(len(counts)):
upper[i] = counts[i]+temperror[i]
lower[i] = counts[i]-temperror[i]
source.change.emit()
exp_dropdown = Select(title="Select:", value="Exp 0", options=list(count_data.Experiment.unique()))
thr_dropdown = Select(title="Select:", value="12", options=list(count_data.Threshold.astype(str).unique()))
exp_dropdown.callback = CustomJS.from_py_func(callback)
p.xgrid.grid_line_color = None
p.legend.orientation = "horizontal"
p.legend.location = "top_center"
layout = column(exp_dropdown,thr_dropdown, p)
show(layout)
The solution to the question is that Select menu needs to be defined before callback function. This code works:
from bokeh.io import show, output_notebook, output_file
from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure
from bokeh.transform import factor_cmap
from bokeh.models import CustomJS, ColumnDataSource, Slider, Select
from bokeh.layouts import column
import numpy as np
import pandas as pd
def generate_data(factor=10):
rawdata = pd.DataFrame(np.random.rand(10,4)*factor, columns = ["A","B","C","D"])
idx = pd.MultiIndex.from_product([["Exp "+str(i) for i in range(5)],
[20,999]],names=["Experiment","Threshold"])
rawdata.index = idx
return rawdata.reset_index()
# Generate data
output_notebook()
count_data = generate_data()
error_data = generate_data(factor=2)
groups = ["A","B","C","D"]
initial_counts = count_data[(count_data.Experiment == "Exp 0")
& (count_data.Threshold == 20)][["A","B","C","D"]].values[0]
initial_errors = error_data[(error_data.Experiment == "Exp 0")
& (error_data.Threshold == 20)][["A","B","C","D"]].values[0]
# Create primary sources of data
count_source = ColumnDataSource(data=count_data)
error_source = ColumnDataSource(data=error_data)
# Create plotting source of data
source = ColumnDataSource(data=dict(groups=groups, counts=initial_counts,
upper=initial_counts+initial_errors,
lower=initial_counts-initial_errors))
# Bar chart and figure
p = figure(x_range=groups, plot_height=350, toolbar_location=None, title="Values", y_range=(0,20))
p.vbar(x='groups', top='counts', width=0.9, source=source, legend="groups",
line_color='white', fill_color=factor_cmap('groups', palette=["#962980","#295f96","#29966c","#968529"],
factors=groups))
# Error bars
p.add_layout(
Whisker(source=source, base="groups", upper="upper", lower="lower", level="overlay")
)
exp_dropdown = Select(title="Select:", value="Exp 0", options=list(count_data.Experiment.unique()))
thr_dropdown = Select(title="Select:", value="20", options=list(count_data.Threshold.astype(str).unique()))
def callback(source=source, count_source = count_source, error_source=error_source, exp_dropdown = exp_dropdown,
thr_dropdown=thr_dropdown,window=None):
def slicer(data_source, experiment, threshold, dummy_col, columns):
""" Helper function to enable lookup of data."""
count = 0
for row in data_source[dummy_col]:
if (data_source["Experiment"][count] == experiment) & (data_source["Threshold"][count] == threshold):
result = [data_source[col][count] for col in columns]
count+=1
return result
# Initialise data sources
data = source.data
count_data = count_source.data
error_data = error_source.data
# Initialise values
experiment = exp_dropdown.value
threshold = thr_dropdown.value
counts, upper, lower = data["counts"], data["upper"], data["lower"]
tempdata = slicer(count_data, experiment, threshold,"Experiment", ["A","B","C","D"])
temperror = slicer(error_data, experiment, threshold,"Experiment", ["A","B","C","D"])
# Select values and emit changes
for i in range(len(counts)):
counts[i] = tempdata[i]
for i in range(len(counts)):
upper[i] = counts[i]+temperror[i]
lower[i] = counts[i]-temperror[i]
source.change.emit()
exp_dropdown.callback = CustomJS.from_py_func(callback)
thr_dropdown.callback = CustomJS.from_py_func(callback)
p.xgrid.grid_line_color = None
p.legend.orientation = "horizontal"
p.legend.location = "top_center"
layout = column(exp_dropdown,thr_dropdown, p)
show(layout)
If I have a scatter plot in bokeh and I've enabled the Box Select Tool, suppose I select a few points with the Box Select Tool. How can I access the (x,y) position location information of the points that I've selected?
%matplotlib inline
import numpy as np
from random import choice
from string import ascii_lowercase
from bokeh.models.tools import *
from bokeh.plotting import *
output_notebook()
TOOLS="pan,wheel_zoom,reset,hover,poly_select,box_select"
p = figure(title = "My chart", tools=TOOLS)
p.xaxis.axis_label = 'X'
p.yaxis.axis_label = 'Y'
source = ColumnDataSource(
data=dict(
xvals=list(range(0, 10)),
yvals=list(np.random.normal(0, 1, 10)),
letters = [choice(ascii_lowercase) for _ in range(10)]
)
)
p.scatter("xvals", "yvals",source=source,fill_alpha=0.2, size=5)
select_tool = p.select(dict(type=BoxSelectTool))[0]
show(p)
# How can I know which points are contained in the Box Select Tool?
I can't call the "callback" attribute and the "dimensions" attribute just returns a list ["width", "height"]. If I can just get the dimensions and the location of the Selected Box, I can figure out which points are in my dataset from there.
You can use a callback on the ColumnDataSource that updates a Python variable with the indices of the selected data:
%matplotlib inline
import numpy as np
from random import choice
from string import ascii_lowercase
from bokeh.models.tools import *
from bokeh.plotting import *
from bokeh.models import CustomJS
output_notebook()
TOOLS="pan,wheel_zoom,reset,hover,poly_select,box_select"
p = figure(title = "My chart", tools=TOOLS)
p.xaxis.axis_label = 'X'
p.yaxis.axis_label = 'Y'
source = ColumnDataSource(
data=dict(
xvals=list(range(0, 10)),
yvals=list(np.random.normal(0, 1, 10)),
letters = [choice(ascii_lowercase) for _ in range(10)]
)
)
p.scatter("xvals", "yvals",source=source,fill_alpha=0.2, size=5)
select_tool = p.select(dict(type=BoxSelectTool))[0]
source.callback = CustomJS(args=dict(p=p), code="""
var inds = cb_obj.get('selected')['1d'].indices;
var d1 = cb_obj.get('data');
console.log(d1)
var kernel = IPython.notebook.kernel;
IPython.notebook.kernel.execute("inds = " + inds);
"""
)
show(p)
Then you can access the desired data attributes using something like
zip([source.data['xvals'][i] for i in inds],
[source.data['yvals'][i] for i in inds])
Here is a working example with Python 3.7.5 and Bokeh 1.4.0
public github link to this jupyter notebook:
https://github.com/surfaceowl-ai/python_visualizations/blob/master/notebooks/bokeh_save_linked_plot_data.ipynb
environment report:
virtual env python version: Python 3.7.5
virtual env ipython version: 7.9.0
watermark package reports:
bokeh 1.4.0
jupyter 1.0.0
numpy 1.17.4
pandas 0.25.3
rise 5.6.0
watermark 2.0.2
# Generate linked plots + TABLE displaying data + save button to export cvs of selected data
from random import random
from bokeh.io import output_notebook # prevent opening separate tab with graph
from bokeh.io import show
from bokeh.layouts import row
from bokeh.layouts import grid
from bokeh.models import CustomJS, ColumnDataSource
from bokeh.models import Button # for saving data
from bokeh.models.widgets import DataTable, DateFormatter, TableColumn
from bokeh.models import HoverTool
from bokeh.plotting import figure
# create data
x = [random() for x in range(500)]
y = [random() for y in range(500)]
# create first subplot
plot_width = 400
plot_height = 400
s1 = ColumnDataSource(data=dict(x=x, y=y))
fig01 = figure(
plot_width=plot_width,
plot_height=plot_height,
tools=["lasso_select", "reset", "save"],
title="Select Here",
)
fig01.circle("x", "y", source=s1, alpha=0.6)
# create second subplot
s2 = ColumnDataSource(data=dict(x=[], y=[]))
# demo smart error msg: `box_zoom`, vs `BoxZoomTool`
fig02 = figure(
plot_width=400,
plot_height=400,
x_range=(0, 1),
y_range=(0, 1),
tools=["box_zoom", "wheel_zoom", "reset", "save"],
title="Watch Here",
)
fig02.circle("x", "y", source=s2, alpha=0.6, color="firebrick")
# create dynamic table of selected points
columns = [
TableColumn(field="x", title="X axis"),
TableColumn(field="y", title="Y axis"),
]
table = DataTable(
source=s2,
columns=columns,
width=400,
height=600,
sortable=True,
selectable=True,
editable=True,
)
# fancy javascript to link subplots
# js pushes selected points into ColumnDataSource of 2nd plot
# inspiration for this from a few sources:
# credit: https://stackoverflow.com/users/1097752/iolsmit via: https://stackoverflow.com/questions/48982260/bokeh-lasso-select-to-table-update
# credit: https://stackoverflow.com/users/8412027/joris via: https://stackoverflow.com/questions/34164587/get-selected-data-contained-within-box-select-tool-in-bokeh
s1.selected.js_on_change(
"indices",
CustomJS(
args=dict(s1=s1, s2=s2, table=table),
code="""
var inds = cb_obj.indices;
var d1 = s1.data;
var d2 = s2.data;
d2['x'] = []
d2['y'] = []
for (var i = 0; i < inds.length; i++) {
d2['x'].push(d1['x'][inds[i]])
d2['y'].push(d1['y'][inds[i]])
}
s2.change.emit();
table.change.emit();
var inds = source_data.selected.indices;
var data = source_data.data;
var out = "x, y\\n";
for (i = 0; i < inds.length; i++) {
out += data['x'][inds[i]] + "," + data['y'][inds[i]] + "\\n";
}
var file = new Blob([out], {type: 'text/plain'});
""",
),
)
# create save button - saves selected datapoints to text file onbutton
# inspriation for this code:
# credit: https://stackoverflow.com/questions/31824124/is-there-a-way-to-save-bokeh-data-table-content
# note: savebutton line `var out = "x, y\\n";` defines the header of the exported file, helpful to have a header for downstream processing
savebutton = Button(label="Save", button_type="success")
savebutton.callback = CustomJS(
args=dict(source_data=s1),
code="""
var inds = source_data.selected.indices;
var data = source_data.data;
var out = "x, y\\n";
for (i = 0; i < inds.length; i++) {
out += data['x'][inds[i]] + "," + data['y'][inds[i]] + "\\n";
}
var file = new Blob([out], {type: 'text/plain'});
var elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(file);
elem.download = 'selected-data.txt';
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
""",
)
# add Hover tool
# define what is displayed in the tooltip
tooltips = [
("X:", "#x"),
("Y:", "#y"),
("static text", "static text"),
]
fig02.add_tools(HoverTool(tooltips=tooltips))
# display results
# demo linked plots
# demo zooms and reset
# demo hover tool
# demo table
# demo save selected results to file
layout = grid([fig01, fig02, table, savebutton], ncols=3)
output_notebook()
show(layout)
# things to try:
# select random shape of blue dots with lasso tool in 'Select Here' graph
# only selected points appear as red dots in 'Watch Here' graph -- try zooming, saving that graph separately
# selected points also appear in the table, which is sortable
# click the 'Save' button to export a csv
# TODO: export from Bokeh to pandas dataframe