Confusion on CustomJS for filtering with MultiChoice

Hello all,

I am trying to produce an interactive plot in an html that filters data by a MultiChoice selection. I am essentially able to produce what I want in the following code with interactive legend options, but when I have too many inputs the plots get too messy.

import pandas as pd
import numpy as np

from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, CustomJS

df = pd.DataFrame()
df['name'] = ['a','a','a','b','b','b','c','c','c']
df['sample'] =np.random.randint(1,3,9)
df['x'] = np.random.randint(0,10,9)
df['y'] = np.random.randint(0,10,9)

plot = figure(width=400, height=400)

for name in df.name.unique():
    
    source = ColumnDataSource(df[df['name']==name].groupby(['sample']).median())
    plot.circle('x', 'y', source=source, legend_label=name)

plot.legend.click_policy="hide"
plot.legend.background_fill_alpha = 0.25

show(plot)

I understand that in order to get the MultiChoice widget to reformat my plot I need to define a CustomJS callback. I have been a little stumped here as I am not familiar with JS. My attempt to get this to work is in the code below.

import pandas as pd
import numpy as np

from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, CustomJS, MultiChoice
from bokeh.layouts import row

df = pd.DataFrame()
df['name'] = ['a','a','a','b','b','b','c','c','c']
df['sample'] =np.random.randint(1,3,9)
df['x'] = np.random.randint(0,10,9)
df['y'] = np.random.randint(0,10,9)

plot = figure(width=400, height=400)

source = ColumnDataSource(df)

# Set up MultiChoice widget
initial_value = [df.name[0]]
options = list(df.name.unique())
multi_choice = MultiChoice(value=initial_value, options=options, max_items=3, title='Selection:')

# dict append function
def set_key(dictionary, key, value):
    if type(dictionary[key]) == list:
        dictionary[key].append(value)
    else:
        dictionary[key] = [dictionary[key], value]
        
    return dictionary

# Create name dict for source
temp_choice_dict = {}
for name in df.name.unique():
    if name == 'a':
        choice_dict = {'name': name}
    else:
        temp_choice_dict = {'name': name}
    for key, value in temp_choice_dict.items():
            choice_dict=set_key(choice_dict,key,value)
            
source_name = ColumnDataSource(name_dict)

# Make plots from source data
if source_name.data['name']==[]:
    source_empty = ColumnDataSource({'x':[],'y':[]})
    plot.circle('x', 'y', source=source_empty)
else:
    for i in range(len(source_name.data['name'])):

        source = ColumnDataSource(df[df['name']==source_name.data['name'][i]].groupby(['sample']).median())
        plot.circle('x', 'y', source=source, legend_label=source_name.data['name'][i])

# Create JS callback
callback = CustomJS(args={'source_name':source_name},code="""  
        console.log(' changed selected option', cb_obj.value);
        source_name.data['name'] = cb_obj.value;
        console.log(' source change', source_name.data['name']);
        source_name.change.emit();
""")


multi_choice.js_on_change('value', callback)

plot.legend.background_fill_alpha = 0.25

show(row(plot,multi_choice))

According to the console log the MultiChoice selections do update the source, but my plot does not update. Thanks in advance for anyone that can point me in the right direction.

Is cb_obj.value a scalar (a single number) here? The values in a CDS .data dict must be columns (e.g arrays or lists).

cb_obj.value should be an array with some length between 0 and 3.

It appears to be reading through correctly based on what I see in the console log.

Actually, looking at your code more closely, I would not expect it to do anything at all. You are updating the "name" column of a CDS, but the "name" column does not actually drive any glyphs or anything else. Updating it is a no-op as far as Bokeh is concerned. I think what you are after is a way to toggle the visibility of different glyphs via a widget (instead of an interactive legend). If so, I’d suggest backing up and studying this simpler example first:

That toggles lines based on a checkbox but it shouldn’t be too far away from a solution with a MultiChoice (you’d loop over the active options and toggle visibility of each corresponding glyph). In any case once you understand how that example works it will be simpler to help with focused questions.

2 Likes

Yes!! This is very close to what I am trying to achieve. Referencing this has gotten me most of the way there. The only issue I have now is that the legend shows all of the glyphs on the plot, and does not change as the glyphs are toggled. Do you know how I could go about getting the legend to show only visible glyphs?

The Legend.items property has a list of LegendItem objects. Starting with Bokeh 2.4 those have a visible property that you can toggle as well. For Bokeh < 2.4 you would have to manually add/remove entire legend items from the legend.

1 Like

Perfect, I was able to get exactly what I need. I did notice an issue with the example you posted where the order in which you checked the boxes mattered. Once I solved that everything behaved as expected. Below is my full solution for what I was trying to achieve. Thank you for all of your help!

import pandas as pd
import numpy as np

from bokeh.plotting import figure, show, row
from bokeh.models import ColumnDataSource, CustomJS, MultiChoice

df = pd.DataFrame()
df['name'] = ['a','a','a','b','b','b','c','c','c']
df['sample'] =np.random.randint(1,3,9)
df['x'] = np.random.randint(0,10,9)
df['y'] = np.random.randint(0,10,9)

plot = figure(width=400, height=400)


name_dict={'name':[],'legend':[],'label':[]}
for name in df.name.unique():  
    source = ColumnDataSource(df[df['name']==name].groupby(['sample']).median())
    name_glyph = plot.circle('x', 'y', source=source, legend_label=name)
    name_dict['name'].append(name_glyph)
    name_dict['label'].append(name)

for label in range(len(df.name.unique())):
    name_dict['legend'].append(plot.legend.items[label])
    
# Set up MultiChoice widget
initial_value = [df.name[0]]
options = list(df.name.unique())
multi_choice = MultiChoice(value=initial_value, options=options, max_items=3, title='Selection:')

for i in range(len(options)):
    if name_dict['label'][i] in initial_value:
        name_dict['name'][i].visible = True;
        name_dict['legend'][i].visible = True;
    else:
        name_dict['name'][i].visible = False;
        name_dict['legend'][i].visible = False;   
    

callback = CustomJS(args=dict(name_dict=name_dict, multi_choice=multi_choice), code="""
var selected_vals = multi_choice.value;
var index_check = [];

for (var i = 0; i < name_dict['name'].length; i++) {
    index_check[i]=selected_vals.indexOf(name_dict['label'][i]);
        if ((index_check[i])>= 0) {
            name_dict['name'][i].visible = true;
            name_dict['legend'][i].visible = true;
            }
        else {
            name_dict['name'][i].visible = false;
            name_dict['legend'][i].visible = false;
        }
    }
""")

multi_choice.js_on_change('value', callback)

plot.legend.background_fill_alpha = 0.25

show(row(plot,multi_choice))
2 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.