Possible to use CustomJS callback from a button to animate a slider?

Hi,

I’m aware that python callbacks (.on_change or .on_click) require bokeh servers to run, and I see that any periodic or time-based callbacks from the bokeh.document class require the bokeh server to python code.
I would like to create a button.js_on_click() (or a toggle equivalent) that updates the value of a slider periodically, executing only CustomJS code, so that the application can be exported to an HTML document and the javascript callbacks will allow it to work independently. The slider has it’s own callback which would then be triggered after the value update, it will change the view of some other graphs (updated using CDS.change.emit()).

Is this possible?

I’ve added somewhat minimal code to help frame the problem, it runs on its own:

import numpy as np
import pandas as pd
import bokeh
from bokeh.plotting import figure, output_file, show
from bokeh.palettes import RdYlGn10
from bokeh.layouts import grid, column, gridplot, layout
from bokeh.models import CustomJS, Slider, ColumnDataSource
from bokeh.models.widgets import Button, TableColumn, Toggle


dict_slice = {} # dict for slice of dataset - a dictionary to be passed into ColumnDataSource (I had problems passing a dataframe before)
dict_full = {} # dict for full dataset - same

# Create fake data to be used in a plot
randoms = np.random.rand(4,100).tolist() # Four lines, 20 datapoints in each line, 5 'slides' in the animation
x_scale = [*range(1,21)]*5 # The x-axis
slices_to_view = [1]*20 + [2]*20 + [3]*20 + [4]*20 + [5]*20

full_data = pd.DataFrame(data=[slices_to_view,x_scale,randoms[0],randoms[1],randoms[2],randoms[3]],index=['Slice','Xaxis','Line1','Line2','Line3','Line4']).T
display(full_data)

slice_data = full_data[full_data.Slice==1]
display(slice_data)

# Add the data to dictionaries
for col in full_data.columns:
    dict_slice[col] = slice_data[col]
    dict_full[col] = full_data[col]

# Put the data into CDS format
sliceCDS = ColumnDataSource(dict_slice)
fullCDS = ColumnDataSource(dict_full)

# Create an additional CDS which contains a unique index for the slices
indexCDS = ColumnDataSource(dict(
                                index=[*range(1,6)]
                                )
                           )

# Construct graph of the initial view, using source=sliceCDS
def graph():
    p = figure(toolbar_location=None)
    for ii in range(1,5):
        p.line(x='Xaxis',y=f'Line{ii}',source=sliceCDS,
               line_color=RdYlGn10[10-2*ii],line_width=2,legend=f'Line{ii} ',muted_alpha=0.2,muted_color=RdYlGn10[10-2*ii])
    p.legend.location = 'top_left'
    p.legend.click_policy = 'mute'
    return p

# Create slider callback
SliderCallback = CustomJS(args = dict(sliceCDS=sliceCDS, fullCDS=fullCDS, indexCDS=indexCDS), code = """
    const new_value = cb_obj.value;
    
    // Take the 'Slice' column from the full data
    const slice_col = fullCDS.data['Slice'];

    // Select only the values equal to the new slice number
    const mask = slice_col.map((item) => item==new_value);

    // Update the data for sliceCDS with a slice of data from fullCDS
    for(i=1; i<5; i++){
        sliceCDS.data['Line' + i.toString()] = fullCDS.data['Line' + i.toString()].filter((item,i) => mask[i]);
    }

    // Update the sliceCDS
    sliceCDS.change.emit();
    """)

# Set up slider
slider = bokeh.models.Slider(title="Slice view number: ",start=1, end=5,
                             value=1,step=1)

slider.js_on_change('value', SliderCallback)

##### Unimplemented section - where the button animation code would go

# Set up Play/Pause button/toggle JS
# toggl_js = CustomJS(args=dict(slider=slider,indexCDS=indexCDS),code="""
#     const currdate = slider.value
#     const datesrem = datearray.filter((item) => item >= currdate)
#     sl.value = currdate
# """)

# toggl = Toggle(label='Play/Pause')
# toggl.js_on_change(toggl_js)

# butt = Button(label="BUTTON", button_type="success")
# butt.js_on_click(toggl_js)

# Set up plot
output_file('AddAnimationPlz.html')
p = figure(plot_width=1500,plot_height=900)
show(bokeh.layouts.layout([[graph()],[slider]],sizing_mode='stretch_both'))
2 Likes

Yes, you will want your Bokeh JS callback for the Toggle to use the standard JavaScript setInterval and clearInterval functions with a little function that sets slider.value according to your needs.

Thank you for your helpful response @Bryan.

For anyone who would like to see the implementation of the code, it is below. I added the typical comforts of setting/resetting the Toggle label, and cycling back to the start of the slider once it reaches the end.

import numpy as np
import pandas as pd
import bokeh
from bokeh.plotting import figure, output_file, show
from bokeh.palettes import RdYlGn10
from bokeh.layouts import grid, column, gridplot, layout
from bokeh.models import CustomJS, Slider, ColumnDataSource
from bokeh.models.widgets import Button, TableColumn, Toggle


dict_slice = {} # dict for slice of dataset - a dictionary to be passed into ColumnDataSource (I had problems passing a dataframe before)
dict_full = {} # dict for full dataset - same

# Create fake data to be used in a plot
randoms = np.random.rand(4,100).tolist() # Four lines, 20 datapoints in each line, 5 'slides' in the animation
x_scale = [*range(1,21)]*5 # The x-axis
slices_to_view = [1]*20 + [2]*20 + [3]*20 + [4]*20 + [5]*20

full_data = pd.DataFrame(data=[slices_to_view,x_scale,randoms[0],randoms[1],randoms[2],randoms[3]],index=['Slice','Xaxis','Line1','Line2','Line3','Line4']).T
display(full_data)

slice_data = full_data[full_data.Slice==1]
display(slice_data)

# Add the data to dictionaries
for col in full_data.columns:
    dict_slice[col] = slice_data[col]
    dict_full[col] = full_data[col]

# Put the data into CDS format
sliceCDS = ColumnDataSource(dict_slice)
fullCDS = ColumnDataSource(dict_full)

# Create an additional CDS which contains a unique index for the slices
indexCDS = ColumnDataSource(dict(
                                index=[*range(1,6)]
                                )
                           )

# Construct graph of the initial view, using source=sliceCDS
def graph():
    p = figure(toolbar_location=None)
    for ii in range(1,5):
        p.line(x='Xaxis',y=f'Line{ii}',source=sliceCDS,
               line_color=RdYlGn10[10-2*ii],line_width=2,legend=f'Line{ii} ',muted_alpha=0.2,muted_color=RdYlGn10[10-2*ii])
    p.legend.location = 'top_left'
    p.legend.click_policy = 'mute'
    return p

# Create slider callback
SliderCallback = CustomJS(args = dict(sliceCDS=sliceCDS, fullCDS=fullCDS, indexCDS=indexCDS), code = """
    const new_value = cb_obj.value;
    
    // Take the 'Slice' column from the full data
    const slice_col = fullCDS.data['Slice'];

    // Select only the values equal to the new slice number
    const mask = slice_col.map((item) => item==new_value);

    // Update the data for sliceCDS with a slice of data from fullCDS
    for(i=1; i<5; i++){
        sliceCDS.data['Line' + i.toString()] = fullCDS.data['Line' + i.toString()].filter((item,i) => mask[i]);
    }

    // Update the sliceCDS
    sliceCDS.change.emit();
    """)

# Set up slider
slider = bokeh.models.Slider(title="Slice view number: ",start=1, end=5,
                             value=1,step=1)

slider.js_on_change('value', SliderCallback)


# Set up Play/Pause button/toggle JS
toggl_js = CustomJS(args=dict(slider=slider,indexCDS=indexCDS),code="""
// A little lengthy but it works for me, for this problem, in this version.
    var check_and_iterate = function(index){
        var slider_val = slider.value;
        var toggle_val = cb_obj.active;
        if(toggle_val == false) {
            cb_obj.label = '► Play';
            clearInterval(looop);
            } 
        else if(slider_val == index[index.length - 1]) {
            cb_obj.label = '► Play';
            slider.value = index[0];
            cb_obj.active = false;
            clearInterval(looop);
            }
        else if(slider_val !== index[index.length - 1]){
            slider.value = index.filter((item) => item > slider_val)[0];
            }
        else {
        clearInterval(looop);
            }
    }
    if(cb_obj.active == false){
        cb_obj.label = '► Play';
        clearInterval(looop);
    }
    else {
        cb_obj.label = '❚❚ Pause';
        var looop = setInterval(check_and_iterate, 800, indexCDS.data['index']);
    };
""")

toggl = Toggle(label='► Play',active=False)
toggl.js_on_change('active',toggl_js)

# Set up plot
output_file('AddAnimationPlz.html')
p = figure(plot_width=1500,plot_height=900)
show(bokeh.layouts.layout([[graph()],[toggl,slider]],sizing_mode='stretch_both'))
6 Likes

@from_mpl I was shocked when your CustomJS worked perfectly for my button on the first try. Thanks for posting your solution

1 Like