Tap event x-y coordinates need to become categorical coordinates

First of all, thanks for Bokeh. I like it for both its richness and its good performance.

I have written code for drawing several Bokeh figures, arranged in a gridplot. I’ve gotten this working in vscode by embedding it in a Panel pn.pane.Bokeh(...). All versions are the latest:
Bokeh 3.1.1
jupyter_bokeh 3.0.7
Panel 1.1.1
vscode 1.79.2

All that does work quite well, and I am able to catch Tap events in python code using a callback:

    def tap_callback(event):
        print(f"model={event.model} x={event.x} "
                f"y={event.y} sx={event.sx} sy={event.sy}")

works and prints out e.g.

model=figure(id='87811164-101d-463f-9491-fafa3085d95b', ...)
x=-0.793488401391503
y=1.5217391304347825
sx=116
sy=533

Now, the figures I created have a categorical y-axis where each tick shows a string (actually a feature name). I created it like this:

        _ = fig.hbar(source=s, y='feature', right='correlation', color="#00330033", width=2)

and it does display the y-axis correctly with names appearing along the ticks.

I need to translate the numerical y-coordinate as above to the feature name. I am new to Bokeh, so I searched all the docs as far as I am able and was not able to find an answer. Am I doing something unusual? Is this easy or difficult?

I am hoping for some functions, perhaps available on the event object or its sub-objetcs (the figure object?), that would do something along the lines of:

    def tap_callback(event):
        name_tapped = event.model.get_name_from_coordinate(event.y)
        print(name_tapped)

Many thanks!
GPN

Just FYI Bokeh 3.2 was released yesterday.

Is this easy or difficult?

It’s a reasonable ask, but I don’t actually think there is any straightforward path here, especially on the Python side. If this were a CustomJS callback, then at least it would be possible in principle to utilize the plot’s mappers to compute the categorical coordinates. But those are only available on the JavaScript side.

I can certainly think of some “hacky” ways to accomplish this, e.g. making long invisible bar glyphs that span each category and then using a selection tool to record taps. The categories could be backed out from the selection index. I’ll have to try to think if there are any better options.

@Bryan I think I found a trick. It relies on the event.y being in data coordinates, so if my feature-names are, say, 30 names - then the data coordinate y will be in the range 0 to 30.

The code for this is:

from math import floor
def tap_callback(event):
    print(f"y={event.y} feature={event.model.y_range.factors[floor(event.y)]}")

I hope this helps someone else. Please let me know if I forgot something important.
Thanks
GPN

Hi @gpn I’m glad that works for you but just for for completeness I should mention that there are things that can affect the mapping from (synthetic) numerical data coordinates to categorical coordinates. E.g if you were using nested categories or some of the more obscure categorical axis settings, then this simple transformation would probably not work as-is. It’s also possible that this mapping could change entirely in the future (I wouldn’t say it is super likely, but it’s also not impossible).

As I mentioned, it’s a reasonable ask to get categorical coordinates on tap when the axis is categorical, so I’d suggest making a GitHub Issue to propose it.

@bryan done. See

[FEATURE] · Issue #13230 · bokeh/bokeh (github.com)

Thanks
GPN

@Bryan
Just FYI I just tried upgrading from 3.1.1 to 3.2.0 but panel complains - they do not yet support your latest release.

(I am using panel for wrapping vscode comms so they are compatible with Bokeh)

@gpn One idea is to use js_on_event and CustomJS on a plot object and having a CDS with the data. Then you can use the index of the selected source to get the categorical text. In the example below I add a tap event callback to the plot p

p.js_on_event('tap', tap_cb)

In the CustomJS code I use source.selected.indices to check whether the source have been hit.
Print result out in the browser console.

  const idx = source.selected.indices;
  var selected = '';
  if (idx.length === 0) {
   selected = 'None';
  } else {
   selected = source.data['fruits'][idx];
  }
  console.log(selected);

Complete code

from bokeh.models import ColumnDataSource, TapTool, CustomJS
from bokeh.palettes import Bright6
from bokeh.plotting import figure, show, save
from bokeh.transform import factor_cmap

fruits = ['Apples', 'Pears', 'Nectarines', 'Plums', 'Grapes', 'Strawberries']
counts = [5, 3, 4, 2, 4, 6]

source = ColumnDataSource(data=dict(fruits=fruits, counts=counts))

p = figure(
    x_range = fruits,
    height = 350,
    toolbar_location = None,
    title = "Fruit Counts"
    )

p.vbar(
    x = 'fruits',
    top = 'counts',
    width = 0.9,
    source = source,
    legend_field = "fruits",
    line_color = 'white',
    fill_color = factor_cmap('fruits', palette=Bright6, factors=fruits)
    )

p.xgrid.grid_line_color = None
p.y_range.start = 0
p.y_range.end = 9
p.legend.orientation = "horizontal"
p.legend.location = "top_center"

# add TapTool to tools and add ´tap event callback
p.add_tools(TapTool(name = 'taptool'))
tap_cb_code = '''
  const idx = source.selected.indices;
  var selected = '';
  if (idx.length === 0) {
   selected = 'None';
  } else {
   selected = source.data['fruits'][idx];
  }
  console.log(selected);
'''
tap_cb = CustomJS(
    args = {'source': source},
    code = tap_cb_code
    )
p.js_on_event('tap', tap_cb)

save(p)
1 Like

@Jonas_Grave_Kristens thanks Jonas. Your example is much appreciated; however, is there any way to update the data from the JS, and get the change transmitted back to the Python code? That is what I am missing in order to be able to use this. Many thanks!
GPN

@gpn based on your initial question I do not know what kind of data you would like to update, maybe it is possible through JS, I can only speculate (in the docs there is a section about JS callbacks).
Anyway, I would assume you can use on_event for a python callback. I am at the moment not able to provide an example since I am traveling.

Just saw that there is an example here in another discourse question

OK so following the advice from everybody (thanks!), here is a code sample which will find the correct categorical value and also update the data of that categorical entry whenever you tap the plot:

        def tap_callback(event):
            feature_index = floor(event.y)
            feature_name = event.model.y_range.factors[feature_index]
            new_value = ... # ommitted irrelevant details

            # create new dict of all the data
            new_data = {}
            for r in event.model.renderers:
                if hasattr(r, 'glyph'):
                    new_data = r.data_source.data.copy()
                    new_data['relevant column name'][-1-feature_index] = new_value
                    r.data_source.data = new_data
                    break
1 Like

@gpn Thanks for sharing the code that make it work as you would like. But to me it seems a bit cumbersome but there might be something I do not understand. I mean you loop renderers in order to find the data_source? If you use the tap event with a callback on the plot you get the index of the source directly. The code below is for the same example I gave above, this time just a python callback.

def tap_cb(src):
    idx = source.selected.indices
    if not idx:
        return

    data = src.data.copy()
    data['counts'][idx[0]] = source.data['counts'][idx[0]]*2
    src.data = data

    p.y_range.end = max(data['counts'])+1

p.on_event('tap', partial(tap_cb, source))

@Jonas_Grave_Kristens thanks, good point! You taught me something new with the way you used partial to make the callback have a signature with src rather than event in the parameters.

In my own code the creation of the callback and the creation of the source are in different places, which I suppose is the reason I did it that way. But I can rework my code to use your approach.

Appreciated.

GPN

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