Alter Selection in ColumnDataSource with customJS without infinite loop

Hello! I am trying to modify data selections made via a DataTable so that more rows are selected than just the ones the user clicked on (e.g. to select all the rows that also match on a certain column). But I am having trouble with an infinite loop in my customJS. Here is an example that I think almost works:

import numpy as np
import pandas as pd
from bokeh.io import show, push_notebook
from bokeh.layouts import column
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, CDSView, CustomJS, CustomJSFilter, Slider, TableColumn, DataTable, SelectEditor

x = np.arange(0, 10, 0.1)
dfs = []
tstep = 1
ts = range(0, 100, tstep)
for t in ts:
    y = x**(t/50.)
    dfs.append(pd.DataFrame({"x": x, "y": y, "t": t}))

df = pd.concat(dfs)
cds = ColumnDataSource(df)

t_slider = Slider(start=ts[0], end=ts[-1], step=tstep, value=0)

# Callback to notify downstream objects of data change
change_callback = CustomJS(args=dict(source=cds), code="""
    source.change.emit();
""")
t_slider.js_on_change('value', change_callback)

# JS filter to select data rows matching t value on slider
js_filter = CustomJSFilter(args=dict(slider=t_slider), code="""
const indices = [];

// iterate through rows of data source and see if each satisfies some constraint
for (let i = 0; i < source.get_length(); i++){
    if (source.data['t'][i] == slider.value){
        indices.push(true);
    } else {
        indices.push(false);
    }
}
return indices;
""")

    
# Use the filter in a view
view = CDSView(filter=js_filter)
                           
# Add table to use for selecting data
columns = [
    TableColumn(field="x", title="x", editor=SelectEditor()),
    TableColumn(field="y", title="y", editor=SelectEditor()),
]
data_table = DataTable(source=cds, columns=columns, editable=True, width=800, view=view)

# Custom JS to select points across all t values that match x,y in the selected row.
custom_js_table = CustomJS(args=dict(source=cds), code="""
const selected_x = [];
const selected_y = [];
for (let i = 0; i < source.selected.indices.length; i++){
    let ind = source.selected.indices[i];
    selected_x.push(source.data['x'][ind]);
    selected_y.push(source.data['y'][ind]);
}

const new_selected_indices = [];
for (let i = 0; i < source.get_length(); i++){
    for (let j = 0; j < selected_x.length; j++){
        if ( (source.data['x'][i] == selected_x[j])
          && (source.data['y'][i] == selected_y[j]) ){
            new_selected_indices.push(i);
        }
    }
}
source.selected.indices = new_selected_indices;
""")
# cds.selected.js_on_change("indices", custom_js_table)


p = figure(x_range=(0,10), y_range=(0,100))
p.scatter(x='x', y='y', source=cds)

layout = column(p) #, t_slider) #, data_table)
    
show(layout)

The issue I guess is that cds.selected.js_on_change("indices", custom_js_table) is triggering on changes to cds.selected.indices, which I then also change in the callback, which I supposed triggers the callback again, looping forever. Is there a smarter way to do this that doesn’t cause an infinite loop?

Self-updates are a perennial issue for any property callback system. There is a fairly obscure “silent” update option that is mostly intended for internal use. But I don’t think it would do what you want (e.g. a silent update would also suppress the datatable redraw as well), so I won’t actually go into the details.

Often this situation is resolved with a flag of some sort, e.g. check whether am_inside_update is true at the top of the callback. If it is true, exit immediately, and if not, set it true until the end of the call when it get set back to false. In the browser you’ll need to maybe store this flag on window or some other “global” location. It’s not especially pretty, or even necessarily 100% robust against all situations, but usually gets the job done.

Ah nice, thanks! I implemented that idea like this, using a Toggle widget to store the “global variable”, and it seems to work:

import numpy as np
import pandas as pd
from bokeh.io import show, push_notebook
from bokeh.layouts import column
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, CDSView, CustomJS, CustomJSFilter, Slider, TableColumn, DataTable, SelectEditor
from bokeh.models.widgets import Toggle

x = np.arange(0, 10, 0.1)
dfs = []
tstep = 1
ts = range(0, 100, tstep)
for t in ts:
    y = x**(t/50.)
    dfs.append(pd.DataFrame({"x": x, "y": y, "t": t}))

df = pd.concat(dfs)
cds = ColumnDataSource(df)

t_slider = Slider(start=ts[0], end=ts[-1], step=tstep, value=0)

# Callback to notify downstream objects of data change
change_callback = CustomJS(args=dict(source=cds), code="""
    source.change.emit();
""")
t_slider.js_on_change('value', change_callback)

# JS filter to select data rows matching t value on slider
js_filter = CustomJSFilter(args=dict(slider=t_slider), code="""
const indices = [];

// iterate through rows of data source and see if each satisfies some constraint
for (let i = 0; i < source.get_length(); i++){
    if (source.data['t'][i] == slider.value){
        indices.push(true);
    } else {
        indices.push(false);
    }
}
return indices;
""")

    
# Use the filter in a view
view = CDSView(filter=js_filter)
                           
# Add table to use for selecting data
columns = [
    TableColumn(field="x", title="x", editor=SelectEditor()),
    TableColumn(field="y", title="y", editor=SelectEditor()),
]
data_table = DataTable(source=cds, columns=columns, editable=True, width=800, view=view)

# Custom JS to select across all t that match the selected x value
mybool = Toggle()  # Kind of a hack to create a variable that persists outside the CustomJS
custom_js_table = CustomJS(args=dict(source=cds, incallback=mybool), code="""
if (incallback.active){
    // Exit immediately to avoid infinite loop
    incallback.active = false;
    return 1;
}
incallback.active = true;
const selected_x = [];
for (let i = 0; i < source.selected.indices.length; i++){
    let ind = source.selected.indices[i];
    selected_x.push(source.data['x'][ind]);
}

const new_selected_indices = [];
for (let i = 0; i < source.get_length(); i++){
    for (let j = 0; j < selected_x.length; j++){
        if (source.data['x'][i] == selected_x[j]){
            new_selected_indices.push(i);
        }
    }
}
source.selected.indices = new_selected_indices;
""")
cds.selected.js_on_change("indices", custom_js_table)

p = figure(x_range=(0,10), y_range=(0,100))
p.scatter(x='x', y='y', source=cds)

layout = column(p, t_slider, data_table)
show(layout)

Using a model is not a bad idea but rather than a Toggle you could define a lightweight DataModel

data_models — Bokeh 3.5.1 Documentation

Ah yeah ok that seems nice. I am having a different problem now though, being that I cannot unselect stuff (because of the way I am adding it). I think this is now just a me being bad at javascript problem though, not a Bokeh problem…

Actually it might be partly a bokeh problem. If I add in some javascript to remove rows that were deselected by the user (by tracking which rows were previously selected and comparing the new selection to that) then I get weird behaviour where the rows flip between selected/unselected as I scroll the t slider. I am wondering if it has something to do with the way the datatable updates the selection. It is like the datatable keeps sending signals to the source to re-select rows even as I try to deselect them in the callback javascript…

I even tried making a button that just sets source.selection.indices = [], which
does deselect at the current slider value, but as soon as I move the slider the other selections pop back up. It is quite confusing…

Here is some code with the button added to show what I mean:

import numpy as np
import pandas as pd
from bokeh.io import show, push_notebook
from bokeh.layouts import column
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, CDSView, CustomJS, CustomJSFilter, Slider, TableColumn, DataTable, SelectEditor, Button


x = np.arange(0, 10, 0.1)
dfs = []
tstep = 1
ts = range(0, 100, tstep)
for t in ts:
    y = x**(t/50.)
    dfs.append(pd.DataFrame({"x": x, "y": y, "t": t}))

df = pd.concat(dfs)
cds = ColumnDataSource(df)

t_slider = Slider(start=ts[0], end=ts[-1], step=tstep, value=0)

# Callback to notify downstream objects of data change
change_callback = CustomJS(args=dict(source=cds), code="""
    source.change.emit();
""")
t_slider.js_on_change('value', change_callback)

# JS filter to select data rows matching t value on slider
js_filter = CustomJSFilter(args=dict(slider=t_slider), code="""
const indices = [];

// iterate through rows of data source and see if each satisfies some constraint
for (let i = 0; i < source.get_length(); i++){
    if (source.data['t'][i] == slider.value){
        indices.push(true);
    } else {
        indices.push(false);
    }
}
return indices;
""")

    
# Use the filter in a view
view = CDSView(filter=js_filter)
                           
# Add table to use for selecting data
columns = [
    TableColumn(field="x", title="x", editor=SelectEditor()),
    TableColumn(field="y", title="y", editor=SelectEditor()),
]
data_table = DataTable(source=cds, columns=columns, editable=True, width=800, view=view)

# Custom JS to select all tracks across all time that match the selected array_id/track_id
from bokeh.models.widgets import Toggle
mybool = Toggle()  # Kind of a hack to create a variable that persists outside the CustomJS
custom_js_table = CustomJS(args=dict(source=cds, incallback=mybool), code="""
if (incallback.active){
    // Exit immediately to avoid infinite loop
    incallback.active = false;
    return 1;
}
incallback.active = true;
const selected_x = [];
for (let i = 0; i < source.selected.indices.length; i++){
    let ind = source.selected.indices[i];
    selected_x.push(source.data['x'][ind]);
}

const new_selected_indices = [];
for (let i = 0; i < source.get_length(); i++){
    for (let j = 0; j < selected_x.length; j++){
        if (source.data['x'][i] == selected_x[j]){
            new_selected_indices.push(i);
        }
    }
}
source.selected.indices = new_selected_indices;
""")
cds.selected.js_on_change("indices", custom_js_table)

p = figure(x_range=(0,10), y_range=(0,100))
p.scatter(x='x', y='y', source=cds, view=view)

# Button to clear selection since I can't seem to manage it via the datatable selection. Selection still pops straight back up when the slider moves though...
clear_button = Button(label="Clear selection", button_type="success")
custom_js_button = CustomJS(args=dict(source=cds), code="""
source.selected.indices = [];
""")
clear_button.js_on_event("button_click", custom_js_button)


layout = column(p, t_slider, data_table, clear_button)

show(layout)

Any help understanding this behaviour would be most appreciated…

Ok so it has nothing to do with my custom JS for selecting across multiple t-slider values. Here’s an example without that where the “clear selection” still only works on the currently-viewed t-slider slice for some mysterious reason:

import numpy as np
import pandas as pd
from bokeh.io import show, push_notebook
from bokeh.layouts import column
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, CDSView, CustomJS, CustomJSFilter, Slider, TableColumn, DataTable, SelectEditor, Button


x = np.arange(0, 10, 0.1)
dfs = []
tstep = 1
ts = range(0, 100, tstep)
for t in ts:
    y = x**(t/50.)
    dfs.append(pd.DataFrame({"x": x, "y": y, "t": t}))

df = pd.concat(dfs)
cds = ColumnDataSource(df)

t_slider = Slider(start=ts[0], end=ts[-1], step=tstep, value=0)

# Callback to notify downstream objects of data change
change_callback = CustomJS(args=dict(source=cds), code="""
    source.change.emit();
""")
t_slider.js_on_change('value', change_callback)

# JS filter to select data rows matching t value on slider
js_filter = CustomJSFilter(args=dict(slider=t_slider), code="""
const indices = [];

// iterate through rows of data source and see if each satisfies some constraint
for (let i = 0; i < source.get_length(); i++){
    if (source.data['t'][i] == slider.value){
        indices.push(true);
    } else {
        indices.push(false);
    }
}
return indices;
""")
    
# Use the filter in a view
view = CDSView(filter=js_filter)
                           
# Add table to use for selecting data
columns = [
    TableColumn(field="x", title="x", editor=SelectEditor()),
    TableColumn(field="y", title="y", editor=SelectEditor()),
]
data_table = DataTable(source=cds, columns=columns, selectable="checkbox", width=800, view=view)

p = figure(x_range=(0,10), y_range=(0,100))
p.scatter(x='x', y='y', source=cds, view=view)

# Button to clear selection
clear_button = Button(label="Clear selection", button_type="success")
custom_js_button = CustomJS(args=dict(source=cds), code="""
source.selected.indices = [];
""")
clear_button.js_on_event("button_click", custom_js_button)

layout = column(p, t_slider, data_table, clear_button)
show(layout)