PointDrawTool and DataTable with CDSViews doing weird stuff

Hi,

I have a last feature in my dashboard that I can seems to get working properly. I have a data table and added the pointdrawtool so the user would be able to remove outliers by clicking on the plot. I tried this separately on a code without the CDSView in it and it deleted the rows from the table when the user clicked on a point and pressed backspace. But when adding that snippet of code to the code below. Its doing weird stuff. Not entirely sure what is happening when the user clears a point from the plot. I have added the code below for anyone who wants to try and see if they understand the issue.


from bokeh.io import output_notebook, show
from bokeh.plotting import figure
from bokeh.models import CustomJS, ColumnDataSource, PointDrawTool, BooleanFilter, MultiSelect, CustomJSTransform
from bokeh.models.widgets import DataTable, TableColumn
from bokeh.transform import transform

output_file("data_table.html")
x=[randint(0, 100) for i in range(10)]
y=[randint(0, 100) for i in range(10)]
total = np.sum([x, y], axis = 0)

source = ColumnDataSource(dict(x=x,
                           y=y,
                           total = total,
                           temp_int=['(20, 30]', '(40, 50]', '(20, 30]', '(20, 30]', '(40, 50]', '(20, 30]', '(20, 30]', '(20, 30]', '(20, 30]', '(40, 50]'],
                           matrix_names=['ras', 'das', 'ras', 'das', 'das', 'bas', 'ras', 'ras', 'ras', 'bas'],
                           names=['a', 'b', 'c', 'c', 'd', 'd', 'a', 'a', 'c', 'c'],
                           name_id=['a-5', 'b-1', 'c-1', 'c-1', 'd-1', 'd-1', 'a-2', 'a-5', 'c-2', 'c-2']))


sourceNew = ColumnDataSource(dict(x=x, y=y, total = total))

columns = [
    TableColumn(field="x", title="x"),
    TableColumn(field="y", title="y"),
    TableColumn(field="total", title="total"),
    TableColumn(field="temp_int", title="temp_int"),
    TableColumn(field="matrix_names", title="matrix_names"),
    TableColumn(field="name_id", title="name_id"),
]

TOOLS = "pan,wheel_zoom,box_select,reset"

options = sorted(set(source.data['names']))
initial_value = [options[0]]
b = BooleanFilter([x in initial_value for x in source.data['names']])
ms = MultiSelect(title='MC Experiment:', value=initial_value, options=options)

id_options = sorted(set(i for n, i in zip(source.data['names'], source.data['name_id'])
                        if n in initial_value))
initial_id_value = id_options#[id_options[0]]
b_id = BooleanFilter([x in initial_id_value for x in source.data['name_id']])
ms_id = MultiSelect(title='ID:', value=initial_id_value, options=id_options)

mn_options = sorted(set(source.data['matrix_names']))
mn_initial_value = [mn_options[0]]
b_mn = BooleanFilter([x in mn_initial_value for x in source.data['matrix_names']])
ms_mn = MultiSelect(title='Matrix Names:', value=mn_initial_value, options = mn_options)

ms.js_on_change('value', CustomJS(args=dict(f=b, source=source, columnName='names', ms_id=ms_id, ms_mn = ms_mn),
                                  code="""\
                                      const val = cb_obj.value;
                                      f.booleans = Array.from(source.data[columnName]).map(d =>  val.includes(d != null && d.toString()));
                                      
                                      // New lines.

                                      const id_options = [...new Set(source.data.name_id.filter((_, idx) => f.booleans[idx]))];
                                      id_options.sort();
                                      
                                      ms_id.options = id_options;
                                      ms_id.value = id_options;

                                      const mn_options = [...new Set(source.data.matrix_names.filter((_, idx) => f.booleans[idx]))];
                                      mn_options.sort();
                                      
                                      ms_mn.options = mn_options;
                                      ms_mn.value = mn_options;

                                      source.change.emit();                                      
                                      """))

ms_id.js_on_change('value', CustomJS(args=dict(f=b_id, source=source, columnName='name_id'),
                                     code="""\
                                      const val = cb_obj.value;
                                      f.booleans = Array.from(source.data[columnName]).map(d =>  val.includes(d != null && d.toString()));
                                      source.change.emit();                                      
                                      """))

view = CDSView(source=source, filters=[b, b_id, b_mn])
tr = CustomJSTransform(v_func="return xs.map(x => 10 * x);")

fig = figure(plot_width=600, plot_height=600, tools=TOOLS)

plot = fig.scatter('x', transform('y', tr), view=view, source=source, color='red')
draw_tool = PointDrawTool(renderers=[plot])
fig.add_tools(draw_tool)

data_table= DataTable(source=source, columns=columns, view=view, width=600, height=600, editable = True)

source.callback = CustomJS(args=dict(source=source, sourceNew = sourceNew), code="""
        var inds = cb_obj.indices;
        var d1 = source.data;
        var d2 = sourceNew.data;

        d2['total'] = [];
        console.log(d1['x'], d1['y'], d1['total']);
        d2['total'].push(parseInt(d1['x']) + parseInt(d1['y']))
        console.log(d2)
        source.change.emit();
    """)

show(row(column(fig, ms, ms_id, ms_mn), data_table))

The code doesn’t work with Bokeh 2.0.2. It lacks some imports, but even if I add them myself, I still get

AttributeError: unexpected attribute 'callback' to ColumnDataSource, similar attributes are js_event_callbacks

Are you on an older Bokeh version where ColumnDataSource has the callback property?

Yes, I am. I can update the version and retry?

Is it because of this line

source.callback = CustomJS(args=dict(source=source, sourceNew = sourceNew), code="""

Should it be like this in the new version: (?)

source.js_event_callbacks = CustomJS(args=dict(source=source, sourceNew = sourceNew), code="""

The old docstring says:

callback = Instance(Callback, help="""
    A callback to run in the browser whenever the selection is changed.

    .. note:
        This property is left for backwards compatibility, but may be deprecated
        in the future. Prefer ``source.selected.js_on_change(...)`` for new code.
    """)

So it should be

source.selected.js_on_change('indices', CustomJS(...))

But it seems that it’s not the only problem. You also have sourceNew which seems to be unused, although you change its data.

Apart from that, it becomes increasingly hard to make sense of the code - it is now far from being a minimal example. In this particular case, it seems that you can remove 2 filters out of 3 along with the table and still observe the behavior that you want to be fixed.

Yeah sorry its becoming a really big example.

Its working exactly how I would hope it will, the only thing I dont understand is when a user selects a point and deletes it from the plot I can see it gets removed from the plot and the table, but if you remove too many points its starts populating the table. The code I initially posted here has been fixed below. If theres no fix for it, I can try a different approach.
Thanks for taking the time to help:)!

from bokeh.io import output_notebook, show
from bokeh.plotting import figure
from bokeh.models import CustomJS, ColumnDataSource, PointDrawTool
from bokeh.models.widgets import DataTable, TableColumn

output_file("data_table.html")
x=[randint(0, 100) for i in range(10)]
y=[randint(0, 100) for i in range(10)]
total = np.sum([x, y], axis = 0)

source = ColumnDataSource(dict(x=x,
                           y=y,
                           total = total,
                           temp_int=['(20, 30]', '(40, 50]', '(20, 30]', '(20, 30]', '(40, 50]', '(20, 30]', '(20, 30]', '(20, 30]', '(20, 30]', '(40, 50]'],
                           matrix_names=['ras', 'das', 'ras', 'das', 'das', 'bas', 'ras', 'ras', 'ras', 'bas'],
                           names=['a', 'b', 'c', 'c', 'd', 'd', 'a', 'a', 'c', 'c'],
                           name_id=['a-5', 'b-1', 'c-1', 'c-1', 'd-1', 'd-1', 'a-2', 'a-5', 'c-2', 'c-2']))

options = sorted(set(source.data['names']))
initial_value = [options[0]]
b = BooleanFilter([x in initial_value for x in source.data['names']])
ms = MultiSelect(title='MC Experiment:', value=initial_value, options=options)

id_options = sorted(set(i for n, i in zip(source.data['names'], source.data['name_id'])
                        if n in initial_value))
initial_id_value = id_options#[id_options[0]]
b_id = BooleanFilter([x in initial_id_value for x in source.data['name_id']])
ms_id = MultiSelect(title='ID:', value=initial_id_value, options=id_options)

ms.js_on_change('value', CustomJS(args=dict(f=b, source=source, columnName='names', ms_id=ms_id),
                                  code="""\
                                      const val = cb_obj.value;
                                      f.booleans = Array.from(source.data[columnName]).map(d =>  val.includes(d != null && d.toString()));
                                      
                                      // New lines.

                                      const id_options = [...new Set(source.data.name_id.filter((_, idx) => f.booleans[idx]))];
                                      id_options.sort();
                                      
                                      ms_id.options = id_options;
                                      ms_id.value = id_options;

                                      source.change.emit();                                      
                                      """))

ms_id.js_on_change('value', CustomJS(args=dict(f=b_id, source=source, columnName='name_id'),
                                  code="""\
                                      const val = cb_obj.value;
                                      f.booleans = Array.from(source.data[columnName]).map(d =>  val.includes(d != null && d.toString()));
                                      source.change.emit();                                      
                                      """))

columns = [
    TableColumn(field="x", title="x"),
    TableColumn(field="y", title="y"),
    TableColumn(field="temp_int", title="temp_int"),
    TableColumn(field="matrix_names", title="matrix_names"),
    TableColumn(field="names", title="names"),
    TableColumn(field="name_id", title="name_id"),
    TableColumn(field="total", title="total"),

]

TOOLS = "pan,wheel_zoom,box_select,reset"
view = CDSView(source=source, filters=[b, b_id])

fig1 = figure(plot_width=600, plot_height=600, tools=TOOLS)
plot = fig1.circle('x', 'y', source=source, view = view, size=10,selection_color="red", hover_color="green")

draw_tool = PointDrawTool(renderers=[plot])
fig1.add_tools(draw_tool)

data_table= DataTable(source=source, columns=columns, view= view, width=600, height=600, editable = True)

source.js_on_change('patching', CustomJS(code="""
var x = cb_obj.attributes.data['x']
var y = cb_obj.attributes.data['y']

var total = y.map(function (num, idx) {
  return parseInt(num) + parseInt(x[idx]);
});

cb_obj.attributes.data['total'] = total
"""
))

p = gridplot([[fig1], [ms], [ms_id], [data_table]],
                        toolbar_location = "above")
tot =row(p)
show(column(tot))

How exactly does a user do that? Either I don’t know something about Bokeh or there’s nothing in your code that removes points.

The PointDrawTool can delete points if you select them then press Backspace: Configuring plot tools — Bokeh 2.4.2 Documentation

Thanks! I didn’t know that.

Fun fact: it seems to work only if my mouse cursor is still over the plot area. If I move the mouse outside, it doesn’t work. That’s why I couldn’t guess it when I tried - I have a habit of moving the mouse out of the way as soon as I select something. :slight_smile:

Right. I’m not sure if it’s a limitation of how focus works or a deliberate feature for safety, but it does help you avoid accidentally deleting things.

When you add or remove points, you have to re-compute the filters because they store the booleans internally.
Try adding this to your code:

source.js_on_change('data', CustomJS(args=dict(ms=ms, ms_id=ms_id),
                                     code="""\
    const curr_ms_id = ms_id.value;
    ms.properties.value.change.emit(ms.value);
    const new_ms_id = curr_ms_id.filter(id => curr_ms_id.includes(id));
    if (new_ms_id.length > 0) {
        ms_id.value = new_ms_id;
        // Have to emit it explicitly in case the value didn't actually change.
        ms_id.properties.value.change.emit(ms_id.value);
    }
"""))

Awesome! That works perfectly! Thanks:D