CrosshairTool Callback

I would like to write a callback that does the following:

  1. ‘Freezes’ the CrosshairTool in a fixed location when the user clicks on that location on the plot. I am attempting to do this by adding Span lines at that location.
  2. Retrieve the X and Y readings from the HoverTool when the user clicks. I have no idea how to do this.

Also, is it possible to adjust that CrosshairTool so that tracks with the plotted line, automatically snapping to the next point of the line as the user moves the cursor.

Thanks!


from bokeh.plotting import figure, ColumnDataSource
from bokeh.models.tools import HoverTool, TapTool, CrosshairTool
from bokeh.models import Span
from bokeh.models.callbacks import CustomJS
import panel as pn
pn.extension()


data= {'x':list(range(0,15)),
      'y': [4, 4, 4, 4, 15, 13, 4, 4, 4, -10, 0, 4, 4, 4, 4]}

source = ColumnDataSource(data)


p = figure(
    height=130,
    width=1800)
line = p.line(x='x', y='y', source= source)
span_width = Span(line_color = 'red', dimension = 'width')
span_height = Span(line_color = 'red', dimension = 'height')

hover = HoverTool(tooltips=[("x", "@x"), ("y", "@y")], point_policy='snap_to_data', mode = 'mouse', line_policy = 'nearest')
crosshair = CrosshairTool(line_color = 'red')
p.add_tools(hover, crosshair)

callback = CustomJS(args = dict(source=source, hover= hover, span_width=span_width, span_height= span_height), code="""

    span_width.location = cb_obj.y
    span_height.location = cb_obj.x
    
""")

p.js_on_event('tap', callback)

pn.Column(p)

I wasn’t able to get the HoverTool reading, but I figured out a work around if it’s helpful to anyone. I created 2 separate spans and both can freeze. The span lines snap to the nearest point when I click on the plot and those points are returned to the console log.

Here’s my callback.

span_callback = CustomJS(args = dict(span_width1 = span_width1, span_height1= span_height1, span_width2 = span_width2, span_height2= span_height2, source=source), code="""
function findClosest(arr, target) {

    let n = arr.length;

    // Corner cases
    if (target <= arr[0])
        return arr[0];
    if (target >= arr[n - 1])
        return arr[n - 1];

    // Doing binary search 
    let i = 0, j = n, mid = 0;
    while (i < j) {
        mid = Math.floor((i + j) / 2);

        if (arr[mid] == target)
            return mid;

        // If target is less than array 
        // element,then search in left 
        if (target < arr[mid]) {

            // If target is greater than previous
            // to mid, return closest of two
            if (mid > 0 && target > arr[mid - 1])
                return getClosest(mid, arr[mid - 1],
                    arr[mid], target);

            // Repeat for left half 
            j = mid;
        }

        // If target is greater than mid
        else {
            if (mid < n - 1 && target < arr[mid + 1])
                return getClosest(mid, arr[mid],
                    arr[mid + 1],
                    target);
            i = mid + 1; // update i
        }
    }

    // Only single element left after search
    return mid;
}

// Method to compare which one is the more close
// We find the closest by taking the difference
//  between the target and both values. It assumes
// that val2 is greater than val1 and target lies
// between these two.
function getClosest(index, val1, val2, target) {
    if (target - val1 >= val2 - target)
        return index + 1;
    else
        return index;
}

let click_index = findClosest(source.data.x, cb_obj.x)

span_width2.location = span_width1.location
span_height2.location = span_height1.location

span_width1.location = source.data.y[click_index]
span_height1.location = source.data.x[click_index]
console.log(span_width1.location)
console.log(span_height1.location)

"""
1 Like

@Stefeni_Butterworth A bit late as you have specified a solution. But I just wanted to give you some comments to what you can do.

In the callback for your TapTool you can check whether an actual index of the CDS has been inspected

const idx = source.inspected.line_indices;

Hence, if length is 0, the user has not clicked on the line glyph, but just some arbitrary point in the figure and no crosshair is shown (with the Spans). If there is an index value, it is always the first index of what defines the current segment of the line. So, one can use that to figure out which data point is nearest.

In the code below I use tags argument of TapTool to store the index of the snapped index. I use this to figure out if the HoverTool should move the crosshair when moving the mouse along the line - if empty array in tags then I assume no crosshair is shown. As you have specified mode = "mouse" in HoverTool one needs to keep the mouse on the line glyph in order to move the crosshair.

In the HoverTool callback I use the same kind of approach as for the TapTool to figure out which index is closest. However, for the hover one needs to use

const idx = cb_data.index.line_indices;

in order to get the index. Again, the index is always the first index of the particular segment of the line.

from bokeh.plotting import figure, ColumnDataSource
from bokeh.models.tools import HoverTool, TapTool, TapTool
from bokeh.models import Span
from bokeh.models.callbacks import CustomJS
from bokeh.io import show, save

data= {'x':list(range(0,15)),
      'y': [4, 4, 4, 4, 15, 13, 4, 4, 4, -10, 0, 4, 4, 4, 4]}

source = ColumnDataSource(data)

p = figure(
    height = 400,
    width = 500
)
line = p.line(
    x = 'x',
    y = 'y',
    source = source
)
# just added circles to better see the data points
p.scatter(
    x = 'x',
    y = 'y',
    line_width = 2,
    fill_color = 'white',
    size = 8,
    source= source
)

span_width = Span(line_color = 'red', dimension = 'width')
span_height = Span(line_color = 'red', dimension = 'height')
p.add_layout(span_width)
p.add_layout(span_height)

hover = HoverTool(
    tooltips = [("x", "@x"), ("y", "@y")],
    point_policy = 'snap_to_data',
    mode = 'mouse',
    line_policy = 'nearest',
    renderers = [line]
    )
tap = TapTool()
p.add_tools(hover, tap)

tap_cb = CustomJS(
    args = dict(
        source = source,
        tap = tap,
        span_width = span_width,
        span_height = span_height
    ), 
    code="""
    const idx = source.inspected.line_indices;

    var snap_x = NaN;
    var snap_y = NaN;
    const data_x = source.data.x;
    const tags = [];

    if (idx.length > 0) {
      const data_dx = data_x[idx[0]+1]-data_x[idx[0]];
      const dx = cb_obj.x-data_x[idx[0]];
      var snap_idx = idx[0];
      if (dx/data_dx > 0.5) {
        snap_idx = idx[0]+1;
      }
      snap_x = data_x[snap_idx];
      snap_y = source.data.y[snap_idx];
      tags.push(snap_idx);
    }
    span_width.location = snap_y;
    span_height.location = snap_x;
    tap.tags = tags;
""")

p.js_on_event('tap', tap_cb)

hover_cb = CustomJS(
    args = dict(
        source = source,
        tap = tap,
        span_width = span_width,
        span_height = span_height
    ), 
    code="""
    const data_x = source.data.x;

    if (tap.tags.length > 0) {
      const idx = cb_data.index.line_indices;
      if (idx.length > 0) { 
        const data_dx = data_x[idx[0]+1]-data_x[idx[0]];
        const dx = cb_data.geometry.x-data_x[idx[0]];
        var snap_idx = idx[0];
        if (dx/data_dx > 0.5) {
          snap_idx = idx[0]+1;
        }
        span_height.location = data_x[snap_idx];
        span_width.location = source.data.y[snap_idx];
      }
    }
""")
hover.callback = hover_cb

save(p)
1 Like

Thank you for your comments! I especially like how you did the HoverTool callback.

1 Like