I'm trying to build a bokeh application with streaming data that tracks multiple "strategies" as they are generated in a prisoners-dilemma agent based model. I've run into a problem trying to get my line plots NOT to connect all the data points in one line. I put together this little demo script that replicates the issue. I've read lots of documentation on line and multi_line rendering in bokeh plots, but I just haven't found something that seems to match my simple case. You can run this code & it will automatically open a bokeh server at localhost:5004 ...
from bokeh.server.server import Server
from bokeh.application import Application
from bokeh.application.handlers.function import FunctionHandler
from bokeh.plotting import figure, ColumnDataSource
from bokeh.models import Button
from bokeh.layouts import column
import random
def make_document(doc):
# Create a data source
data_source = ColumnDataSource({'step': [], 'strategy': [], 'ncount': []})
# make a list of groups
strategies = ['DD', 'DC', 'CD', 'CCDD']
# Create a figure
fig = figure(title='Streaming Line Plot',
plot_width=800, plot_height=400)
fig.line(x='step', y='ncount', source=data_source)
global step
step = 0
def button1_run():
global callback_obj
if button1.label == 'Run':
button1.label = 'Stop'
button1.button_type='danger'
callback_obj = doc.add_periodic_callback(button2_step, 100)
else:
button1.label = 'Run'
button1.button_type = 'success'
doc.remove_periodic_callback(callback_obj)
def button2_step():
global step
step = step+1
for i in range(len(strategies)):
new = {'step': [step],
'strategy': [strategies[i]],
'ncount': [random.choice(range(1,100))]}
fig.line(x='step', y='ncount', source=new)
data_source.stream(new)
# add on_click callback for button widget
button1 = Button(label="Run", button_type='success', width=390)
button1.on_click(button1_run)
button2 = Button(label="Step", button_type='primary', width=390)
button2.on_click(button2_step)
doc.add_root(column(fig, button1, button2))
doc.title = "Now with live updating!"
apps = {'/': Application(FunctionHandler(make_document))}
server = Server(apps, port=5004)
server.start()
if __name__ == '__main__':
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
My hope was that by looping thru the 4 "strategies" in the example (after clicking button2), I could stream the new data coming out of the simulation into a line plot for that one strategy and step only. But what I get is one line with all four values connected vertically, then one of them connected to the first one at the next step. Here's what it looks like after a few steps:
I noticed that if I move data_source.stream(new) out of the for loop, I get a nice single line plot, but of course it is only for the last strategy coming out of the loop.
In all the bokeh multiple line plotting examples I've studied (not the multi_line glyph, which I can't figure out and which seems to have some issues with the Hover tool), the instructions seem pretty clear: if you want to render a second line, you add another fig.line renderer to an existing figure, and it draws a line with the data provided in source=data_source for this line. But even though my for-loop collects and adds data separately for each strategy, I don't get 4 line plots, I get only one.
Hoping I'm missing something obvious! Thanks in advance.
Seems like you need a line per strategy, not a line per step. If so, here's how I would do it:
import random
from bokeh.application import Application
from bokeh.application.handlers.function import FunctionHandler
from bokeh.layouts import column
from bokeh.models import Button
from bokeh.palettes import Dark2
from bokeh.plotting import figure, ColumnDataSource
from bokeh.server.server import Server
STRATEGIES = ['DD', 'DC', 'CD', 'CCDD']
def make_document(doc):
step = 0
def new_step_data():
nonlocal step
result = [dict(step=[step],
ncount=[random.choice(range(1, 100))])
for _ in STRATEGIES]
step += 1
return result
fig = figure(title='Streaming Line Plot', plot_width=800, plot_height=400)
sources = []
for s, d, c in zip(STRATEGIES, new_step_data(), Dark2[4]):
# Generate the very first step right away
# to avoid having a completely empty plot.
ds = ColumnDataSource(d)
sources.append(ds)
fig.line(x='step', y='ncount', source=ds, color=c)
callback_obj = None
def button1_run():
nonlocal callback_obj
if callback_obj is None:
button1.label = 'Stop'
button1.button_type = 'danger'
callback_obj = doc.add_periodic_callback(button2_step, 100)
else:
button1.label = 'Run'
button1.button_type = 'success'
doc.remove_periodic_callback(callback_obj)
def button2_step():
for src, data in zip(sources, new_step_data()):
src.stream(data)
# add on_click callback for button widget
button1 = Button(label="Run", button_type='success', width=390)
button1.on_click(button1_run)
button2 = Button(label="Step", button_type='primary', width=390)
button2.on_click(button2_step)
doc.add_root(column(fig, button1, button2))
doc.title = "Now with live updating!"
apps = {'/': Application(FunctionHandler(make_document))}
server = Server(apps, port=5004)
if __name__ == '__main__':
server.io_loop.add_callback(server.show, "/")
server.start()
server.io_loop.start()
Thank you, Eugene. Your solution got me back on the right track. I played around with it a bit more and ended up with the following:
import colorcet as cc
from bokeh.server.server import Server
from bokeh.application import Application
from bokeh.application.handlers.function import FunctionHandler
from bokeh.plotting import figure, ColumnDataSource
from bokeh.models import Button
from bokeh.layouts import column
import random
def make_document(doc):
# make a list of groups
strategies = ['DD', 'DC', 'CD', 'CCDD']
# initialize some vars
step = 0
callback_obj = None
colors = cc.glasbey_dark
# create a list to hold all CDSs for active strategies in next step
sources = []
# Create a figure container
fig = figure(title='Streaming Line Plot - Step 0', plot_width=800, plot_height=400)
# get step 0 data for initial strategies
for i in range(len(strategies)):
step_data = dict(step=[step],
strategy = [strategies[i]],
ncount=[random.choice(range(1, 100))])
data_source = ColumnDataSource(step_data)
color = colors[i]
# this will create one fig.line renderer for each strategy & its data for this step
fig.line(x='step', y='ncount', source=data_source, color=color, line_width=2)
# add this CDS to the sources list
sources.append(data_source)
def button1_run():
nonlocal callback_obj
if button1.label == 'Run':
button1.label = 'Stop'
button1.button_type='danger'
callback_obj = doc.add_periodic_callback(button2_step, 100)
else:
button1.label = 'Run'
button1.button_type = 'success'
doc.remove_periodic_callback(callback_obj)
def button2_step():
nonlocal step
data = []
step += 1
fig.title.text = 'Streaming Line Plot - Step '+str(step)
for i in range(len(strategies)):
step_data = dict(step=[step],
strategy = [strategies[i]],
ncount=[random.choice(range(1, 100))])
data.append(step_data)
for source, data in zip(sources, data):
source.stream(data)
# add on_click callback for button widget
button1 = Button(label="Run", button_type='success', width=390)
button1.on_click(button1_run)
button2 = Button(label="Step", button_type='primary', width=390)
button2.on_click(button2_step)
doc.add_root(column(fig, button1, button2))
doc.title = "Now with live updating!"
apps = {'/': Application(FunctionHandler(make_document))}
server = Server(apps, port=5004)
server.start()
if __name__ == '__main__':
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
Result is just what I was looking for ...
Related
I have a dashboard application that works with Bokeh. I am trying to change it to use Panel and Geoviews. I am using the Panel Callback API, as this seems most like my existing code with Bokeh. I am running a regular Python script with the Panel server.
When my callback creates the new plot for the widgets selection then Panel displays an additional plot instead of updating the existing plot. Using "servable" causes an additional plot in the existing browser window, using "show" displays an additional window. How do I update the existing plot?
Here is some test code. (The full application displays a choropleth map with Geo data, and has many more widgets with code that reads different data, but this code illustrates the problem.)
import census_read_data as crd
import census_read_geopandas as crg
import pandas as pd
import geopandas as gpd
import geoviews as gv
from bokeh.plotting import show
from bokeh.models import PrintfTickFormatter
import panel as pn
import hvplot.pandas
# Get Census Merged Ward and Local Authority Data
# Replaced by test DataFrame
geography = pd.DataFrame(data=[
['E36007378', 'Chiswick Riverside', 'E09000018', 'Hounslow'],
['E36007379', 'Cranford', 'E09000018', 'Hounslow'],
['E36007202', 'Ealing Broadway', 'E09000009', 'Ealing'],
['E36007203', 'Ealing Common', 'E09000009', 'Ealing'],
['E36007204', 'East Acton', 'E09000009', 'Ealing'],
['E09000018', 'Hounslow', 'E09000018', 'Hounslow'],
['E09000009', 'Ealing', 'E09000009', 'Ealing']
], columns=["GeographyCode", "Name", "LAD11CD", "LAD11NM"])
# Get London Ward GeoPandas DataFrame
# Replaced by test DataFrame
london_wards_data_gdf = pd.DataFrame(data=[
['E36007378', 'E09000018', 378],
['E36007379', 'E09000018', 379],
['E36007202', 'E09000009', 202],
['E36007203', 'E09000009', 203],
['E36007204', 'E09000009', 204]
], columns=["cmwd11cd", "lad11cd", "data"])
# Get LAD GeoPandas DataFrame
# Replaced by test DataFrame
london_lads_data_gdf = pd.DataFrame(data=[
['E09000018', 757],
['E09000009', 609]
], columns=["lad11cd", "data"])
locationcol = "GeographyCode"
namecol = "Name"
datacol = 'data'
# Panel
pn.extension('bokeh')
gv.extension('bokeh')
lad_max_value = london_lads_data_gdf[datacol].max()
ward_max_value = london_wards_data_gdf[datacol].max()
title = datacol + " by Local Authority"
local_authorities = geography['LAD11CD'].unique()
granularities = ['Local Authorities', 'Wards']
# Create Widgets
granularity_widget = pn.widgets.RadioButtonGroup(options=granularities)
local_authority_widget = pn.widgets.Select(name='Wards for Local Authority',
options=['All'] +
[geography[geography[locationcol] == lad][namecol].iat[0]
for lad in local_authorities],
value='All')
widgets = pn.Column(granularity_widget, local_authority_widget)
layout = widgets
def update_graph(event):
# Callback recreates map when granularity or local_authority are changed
global layout
granularity = granularity_widget.value
local_authority_name = local_authority_widget.value
print(f'granularity={granularity}')
if granularity == 'Local Authorities':
gdf = london_lads_data_gdf
max_value = lad_max_value
title = datacol + " by Local Authority"
else:
max_value = ward_max_value
if local_authority_name == 'All':
gdf = london_wards_data_gdf
title = datacol + " by Ward"
else:
local_authority_id = geography[geography['Name'] ==
local_authority_name].iloc[0]['GeographyCode']
gdf = london_wards_data_gdf[london_wards_data_gdf['lad11cd'].str.match(
local_authority_id)]
title = datacol + " by Ward for " + local_authority_name
# Replace gv.Polygons with hvplot.bar for test purposes
map = gdf.hvplot.bar(y=datacol, height=500)
layout = pn.Column(widgets, map)
# With servable, a new plot is added to the browser window each time the widgets are changed
# layout.servable()
# With servable, a new browser window is shown each time the widgets are changed
layout.show()
granularity_widget.param.watch(update_graph, 'value')
local_authority_widget.param.watch(update_graph, 'value')
update_graph(None)
# panel serve panel_test_script.py --show
I ended up implementing my solution using Params, rather than callbacks, which worked great. However, I eventually saw Dynamically updating a Holoviz Panel layout which showed me a solution to my original question.
The callback should not show() a new layout (with the new map), but should simply update the existing layout, replacing the existing map with the new map. As I write this it seems obvious!
This code fragment shows the solution:
...
widgets = pn.Column(granularity_widget, local_authority_widget)
empty_map = pn.pane.Markdown('### Map placeholder...')
layout = pn.Column(widgets, empty_map)
def update_graph(event):
...
# Replace gv.Polygons with hvplot.bar for test purposes
map = gdf.hvplot.bar(y=datacol, height=500)
# Update existing layout with new map
layout[1] = map
granularity_widget.param.watch(update_graph, 'value')
local_authority_widget.param.watch(update_graph, 'value')
# Invoke callback to show map for initial widget values
update_graph(None)
layout.show()
I'm trying to update Slope y_intercept from another thread in bokeh server. But I can not trigger js_on_change, it seems the javascript code is not even generated. Could someone let me what's wrong here? This is the whole code (updated to use add_periodic_callback()):
#!/usr/bin/env python3
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Slope, CustomJS
from bokeh.plotting import figure
from bokeh.server.server import Server
import pandas as pd
source = ColumnDataSource()
n = 0
def update():
global n
source.data = pd.DataFrame({"y": [n]})
print(f"{source.data=}")
n += 1
def bkapp(doc):
plot = figure(y_range=(-10, 10))
plot.line([-10, 10], [-10, 10])
slope = Slope(gradient=0, y_intercept=0, line_color='black')
plot.add_layout(slope)
source.js_on_change('data', CustomJS(
args=dict(ds=source, slopes=slope), code="""
console.log("whereismycode");
slope.y_intercept = ds.data["y"][0];
"""
))
doc.add_root(plot)
doc.add_periodic_callback(update, 5000)
server = Server({'/': bkapp}, port=5103, num_procs=1)
server.start()
if __name__ == '__main__':
print(f'address = {server.address}, port = {server.port}')
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
Purely based on the question, exactly as presented above, no CDS is necessary at all in order to update the slope, and adding one is an over-complication. Here is the code I would write:
import pandas as pd
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, CustomJS, Slope
from bokeh.plotting import figure
from bokeh.server.server import Server
n = 0
def bkapp(doc):
plot = figure(y_range=(-10, 10))
plot.line([-10, 10], [-10, 10])
slope = Slope(gradient=0, y_intercept=0, line_color='black')
plot.add_layout(slope)
def update():
global n
slope.y_intercept = n
n += 1
doc.add_root(plot)
doc.add_periodic_callback(update, 5000)
server = Server({'/': bkapp}, port=5103, num_procs=1)
server.start()
if __name__ == '__main__':
print(f'address = {server.address}, port = {server.port}')
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
As an aside, note that if you expect at all to have more than one user/session at a time, then there cannot be any "global" Bokeh models (e.g. like the global CDS in your case). The bkapp function must return an entirely unique and new set of objects for each session.
This is the working code. as Tim Roberts pointed out, the issue is that bokeh doc doesn't know about the data source. A hacky solution is to attached it to a hidden glyph. #bigreddot, maybe there's a special method just for this purpose ?
#!/usr/bin/env python3
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Slope, CustomJS, DataTable
from bokeh.plotting import figure
from bokeh.server.server import Server
from bokeh.layouts import column
import pandas as pd
source = ColumnDataSource()
n = 0
def update():
global n
source.data = pd.DataFrame({"y": [n]})
print(f"{source.data=}")
n += 1
def bkapp(doc):
plot = figure(y_range=(-10, 10))
plot.line([-10, 10], [-10, 10])
slope = Slope(gradient=0, y_intercept=0, line_color='black')
plot.add_layout(slope)
table = DataTable(source=source, visible=False)
source.js_on_change('data', CustomJS(
args=dict(ds=source, slope=slope), code="""
console.log("whereismycode");
slope.y_intercept = ds.data["y"][0];
"""
))
doc.add_root(column([plot, table]))
doc.add_periodic_callback(update, 5000)
server = Server({'/': bkapp}, port=5103, num_procs=1)
server.start()
if __name__ == '__main__':
print(f'address = {server.address}, port = {server.port}')
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
I want to create a visualization with CheckboxGroup, which shows the line of the currency in the graph if the checkbox of this currency is activated. In addition i want one Button 'Select all' and one 'Select none' to select all or none currency at once.
By now, I have this code but I get the following error:
unexpected attribute 'checkbox' to CustomJS, possible attributes are args, code, js_event_callbacks, js_property_callbacks, name, subscribed_events or tags
I would appreciate a check of my code and some help. Thank you
from bokeh.io import output_file, show, save
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, HoverTool, FactorRange, CheckboxGroup, CustomJS, Button
from bokeh.layouts import row, column
...
args = []
code = "active = cb_obj.active;"
for c in range(len(currencies)):
line = p.line(x='dates', y=currencies[c], line_width=2, alpha=1, name=currencies[c], legend_label=currencies[c], source=source)
args += [('line'+str(c), line)]
code += "line{}.visible = active.includes({});".format(c, c)
...
checkbox_group = CheckboxGroup(labels=currencies, active=list(range(len(currencies))))
checkbox_group.callback = CustomJS(args={key:value for key,value in args}, checkbox=checkbox_group, code=code)
def callback_button_on():
checkbox_group.active = list(range(len(currencies)))
def callback_button_off():
checkbox_group.active = []
select_all = Button(label='Select all')
select_all.on_click(callback_button_on)
select_none = Button(label='Select none')
select_none.on_click(callback_button_off)
group = column(select_all, select_none, checkbox_group)
show(row(group, p))
output_file("Daily_Returns.html")
A CustomJS callback does not accept arbitrary arguments, but all bokeh models that should be available in the javascript code must be part of the args dictionary. Simply add the checkbox group into it:
args = []
args.append(("checkbox", checkboxgroup))
code = "active = cb_obj.active;"
...
checkbox_group.callback = CustomJS(args={key:value for key,value in args}, code=code)
or if your code has no explicit mention of the checkboxgroup (i tonly uses cb_obj) you can also just not pass the checkbox to the CustomJS callback.
This code works for Boekh v2.1.1
There were a few issues with your code:
Callback data is only allowed inside args dictionary
In JS you need to always use var for variable declaration => var active = cb_obj.active
Your Python callbacks for buttons won't work in a stand-alone HTML page. Python callbacks only work in a server app. For your stand-alone HTML page use only JS callbacks => js_on_click.
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, CheckboxGroup, CustomJS, Button
from bokeh.layouts import row, column
from datetime import datetime, timedelta
currencies = ['EUR', 'GBP']
data = {'dates': [datetime(2017,1,1) + timedelta(days=x) for x in range(0,3)], 'EUR': [1.3, 1.1, 1.3], 'GBP': [1.6, 1.7, 1.6]}
source = ColumnDataSource(data)
p = figure(x_axis_type="datetime")
checkbox_group = CheckboxGroup(labels=currencies, active=list(range(len(currencies))))
select_all = Button(label='Select all')
select_none = Button(label='Select none')
args = [('checkbox', checkbox_group)]
code = "var active = cb_obj.active;"
for c in range(len(currencies)):
line = p.line(x='dates', y=currencies[c], line_width=2, alpha=1, name=currencies[c], legend_label=currencies[c], source=source)
args += [('line'+str(c), line)]
code += "line{}.visible = active.includes({});".format(c, c)
checkbox_group.js_on_change('active', CustomJS(args={key:value for key,value in args}, code=code))
select_all.js_on_click(CustomJS(args={'checkbox_group': checkbox_group, 'currencies': currencies}, code="checkbox_group.active = Array.from(currencies, (x, i) => i);"))
select_none.js_on_click(CustomJS(args={'checkbox_group': checkbox_group}, code="checkbox_group.active = [];"))
group = column(select_all, select_none, checkbox_group)
show(row(group, p))
I want Bokeh to update periodically and arbitrarily when the results from a separate algorithm running in python returns results, not based on any input from the Bokeh interface.
I've tried various solutions but they all depend on a callback to a some UI event or a periodic callback as in the code below.
import numpy as np
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource, Plot, LinearAxis, Grid
from bokeh.models.glyphs import MultiLine
from time import sleep
from random import randint
def getData(): # simulate data acquisition
# run slow algorith
sleep(randint(2,7)) #simulate slowness of algorithm
return dict(xs=np.random.rand(50, 2).tolist(), ys=np.random.rand(50, 2).tolist())
# init plot
source = ColumnDataSource(data=getData())
plot = Plot(
title=None, plot_width=600, plot_height=600,
min_border=0, toolbar_location=None)
glyph = MultiLine(xs="xs", ys="ys", line_color="#8073ac", line_width=0.1)
plot.add_glyph(source, glyph)
xaxis = LinearAxis()
plot.add_layout(xaxis, 'below')
yaxis = LinearAxis()
plot.add_layout(yaxis, 'left')
plot.add_layout(Grid(dimension=0, ticker=xaxis.ticker))
plot.add_layout(Grid(dimension=1, ticker=yaxis.ticker))
curdoc().add_root(plot)
# update plot
def update():
bokeh_source = getData()
source.stream(bokeh_source, rollover=50)
curdoc().add_periodic_callback(update, 100)
This does seem to work, but is this the best way to go about things? Rather than having Bokeh try to update every 100 milliseconds can I just push new data to it when it becomes available?
Thanks
You can use zmq and asyncio to do it. Here is the code for the bokeh server, it wait data in an async coroutine:
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, curdoc
from functools import partial
from tornado.ioloop import IOLoop
import zmq.asyncio
doc = curdoc()
context = zmq.asyncio.Context.instance()
socket = context.socket(zmq.SUB)
socket.connect("tcp://127.0.0.1:1234")
socket.setsockopt(zmq.SUBSCRIBE, b"")
def update(new_data):
source.stream(new_data, rollover=50)
async def loop():
while True:
new_data = await socket.recv_pyobj()
doc.add_next_tick_callback(partial(update, new_data))
source = ColumnDataSource(data=dict(x=[0], y=[0]))
plot = figure(height=300)
plot.line(x='x', y='y', source=source)
doc.add_root(plot)
IOLoop.current().spawn_callback(loop)
to send the data just run following code in another python process:
import time
import random
import zmq
context = zmq.Context.instance()
pub_socket = context.socket(zmq.PUB)
pub_socket.bind("tcp://127.0.0.1:1234")
t = 0
y = 0
while True:
time.sleep(1.0)
t += 1
y += random.normalvariate(0, 1)
pub_socket.send_pyobj(dict(x=[t], y=[y]))
I'm putting together a bokeh server to collect multiple streams of data, and provide a live plot of whichever channel the user selects in a MultiSelect menu. I have the streaming bit working, but I'm not sure how to select which stream is displayed in the figure that I've added to the layout.
I've tried using curdoc().remove_root() to remove the current layout and then add a new one, but that just kills the app and the new layout doesn't show up. I've also tried to simply update the figure, but that also just kills the app.
from bokeh.layouts import column
from bokeh.plotting import figure,curdoc
from bokeh.models import ColumnDataSource
from bokeh.models.widgets import MultiSelect
def change_plot(attr,old,new):
global model,selector,p,source
curdoc().remove_root(mode)
p = figure()
p.circle(x=new+'_x',y=new+'_y',source=source)
model = column(selector,p)
curdoc().add_root(model)
def update_plot():
newdata = {}
for i in range(10):
# the following two lines would nominally provide real data
newdata[str(i)+'_x'] = 1
newdata[str(i)+'_y'] = 1
source.stream(newdata,100)
selector = MultiSelect(title='Options',value=[str(i) for i in range(10)])
selector.on_change('value',change_plot)
data = {}
for i in range(10):
data[str(i)+'_x'] = 0
data[str(i)+'_y'] = 0
source = ColumnDataSource(data=data)
p = figure()
p.circle(x='0_x',y='0_y',source=source)
curdoc().add_root(model)
curdoc().add_periodic_callback(update_plot,100)
I run this code using bokeh serve --show app.py, and I would've expected it to create a new plot every time the MultiSelect is updated, but instead, it just crashes somewhere in the change_plot callback.
In this code selecting a line in MultiSelect adds a new line if it was not in the canvas and starts streaming or just toggles streaming if the line already was in the canvas. Code works for Bokeh v1.0.4. Run with bokeh serve --show app.py
from bokeh.models import ColumnDataSource, MultiSelect, Column
from bokeh.plotting import figure, curdoc
from datetime import datetime
from random import randint
from bokeh.palettes import Category10
lines = ['line_{}'.format(i) for i in range(10)]
data = [{'time':[], item:[]} for item in lines]
sources = [ColumnDataSource(item) for item in data]
plot = figure(plot_width = 1200, x_axis_type = 'datetime')
def add_line(attr, old, new):
for line in new:
if not plot.select_one({"name": line}):
index = lines.index(line)
plot.line(x = 'time', y = line, color = Category10[10][index], name = line, source = sources[index])
multiselect = MultiSelect(title = 'Options', options = [(i, i) for i in lines], value = [''])
multiselect.on_change('value', add_line)
def update():
for line in lines:
if line in multiselect.value:
if plot.select({"name": line}):
sources[lines.index(line)].stream(eval('dict(time = [datetime.now()], ' + line + ' = [randint(5, 10)])'))
curdoc().add_root(Column(plot, multiselect))
curdoc().add_periodic_callback(update, 1000)
Result: