Constrain path of point along a curve interactively

I have a univariate function that defines a performance measure as a function of an operating point. As part of visualization and dynamic specification of the operating point, I am attempting to drag a marker along the curve.

An attempt at uses the PointDrawTool. Several unexpected behaviors have been encountered, and help on how to accomplish more intuitively and robustly is appreciated. An example python file using the bokeh server model is attached to support the discussion.

Issues:

1b. For some performance functions, the dragging along the curve is smooth and generally intuitive from a user perspective (lefthand plot/function in the attached example).

1b. For other performance functions, the dragging along the curve is counterintuitive, with marker/circle moving opposite the expected mouse motions although it does remain on the constraining function.

2a. A secondary interactive feature to include, is clicking on the plot to have the operating point jump to the x-axis location and the corresponding y-axis location equal the performance measure function at that location. This is attempted using the num_objects=1 setting in the PointDrawTool.

This doesn’t appear to work either. A marker does show up in the expected location with this action (expected), but the original point remains and generally moves around (unexpected).

2b. The point-draw tool doesn’t seem to support column data sources based on pandas dataframes, but rather only works with dictionaries. This seems incosistent with other glyphs and models. This is not a functional limitation for the current use case, just a noted difference.

Thanks in advance for any help.

EXAMPLE

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
"""
import numpy as np
import pandas as pd

from bokeh.plotting import figure, curdoc
from bokeh.layouts import row

from bokeh.models import ColumnDataSource
from bokeh.models import Line, Circle
from bokeh.models import PointDrawTool, HoverTool

from bokeh.models.transforms import LinearInterpolator
from bokeh.transform import transform

from dataclasses import dataclass


@dataclass
class DS:
    data    : pd.DataFrame
    source  : ColumnDataSource

@dataclass    
class UI:
    plot    : figure
    curve   : Line
    r_sel   : Circle
    li_x    : LinearInterpolator
    li_y    : LinearInterpolator
    tr_x    : transform
    tr_y    : transform
    drawtool: PointDrawTool
    hover   : HoverTool
    
op = np.linspace(start=0.000, stop=1.000, num=1001)
fA = (2.0/np.pi) * np.arctan(10.0*op)
fB = np.exp(-np.pi*(op ** 2))

_data = pd.DataFrame(np.column_stack((op,fA,fB)), columns=('op','fA','fB'))
_source = ColumnDataSource(_data)
ds = DS(data=_data, source=_source)

uis = []
mdls = []
for f in ('fA','fB',):
    # Performance curve
    p = figure(plot_width=500, plot_height=500, x_range=(0.0,1.0), y_range=(0.0,1.0))
    ln = p.line(x='op', y=f, source=ds.source, line_color='#4292c6')

    # Selected operating point along performance curve
    li_x = LinearInterpolator(x='op', y='op', data=ds.source, clip=False)
    li_y = LinearInterpolator(x='op', y=f   , data=ds.source, clip=False)

    tr_x = transform('op', li_x)
    tr_y = transform('op', li_y)

    _opd = {'op': [ds.data.op[500]]}
    _ops = ColumnDataSource(_opd)

    r_sel = p.circle(x='op', y=tr_y, source=_ops, color='#000000', size=12, name='op_'+f)

    drawtool = PointDrawTool(renderers=[r_sel], add=True, num_objects=1)
    p.add_tools(drawtool)
    p.toolbar.active_tap = drawtool

    h = HoverTool(names=['op_'+f],
                         tooltips=[('Operating Point', '$x{%5.3f}'),(f, '$y{%5.3f}')],
                         formatters={'$x': 'printf', '$y': 'printf'},
                         mode='vline')
    p.add_tools(h)

    _ui = UI(plot=p, curve=ln, r_sel=r_sel,
             li_x=li_x, li_y=li_y, tr_x=tr_x, tr_y=tr_y,
             drawtool=drawtool, hover=h)

    uis += [_ui]
    mdls += [_ui.plot]

curdoc().add_root(row((mdls)))

This is a simplified version of the code that exhibits the same behavior:

import numpy as np
from bokeh.models import ColumnDataSource
from bokeh.models import PointDrawTool
from bokeh.models.transforms import LinearInterpolator
from bokeh.plotting import figure, curdoc
from bokeh.transform import transform

op = np.linspace(start=0.000, stop=1.000, num=1001)
source = ColumnDataSource(dict(op=op,
                               fA=(2.0 / np.pi) * np.arctan(10.0 * op)))

p = figure(plot_width=500, plot_height=500, x_range=(0.0, 1.0), y_range=(0.0, 1.0))

p.line(x='op', y='fA', source=source, line_color='#4292c6')

li_y = LinearInterpolator(x='op', y='fA', data=source, clip=False)

ops = ColumnDataSource({'op': [source.data['op'][500]]})
r_sel = p.circle(x='op', y=transform('op', li_y), source=ops, color='#000000', size=12, name='op_fA')

drawtool = PointDrawTool(renderers=[r_sel], add=True, num_objects=1)
p.add_tools(drawtool)
p.toolbar.active_tap = drawtool

curdoc().add_root(p)

The issue with double points is a genuine issue with PointDrawTool - it checks for field but it doesn’t check if it’s a transformed field.
I’m not sure how to solve it so that it works in every case. I don’t think it’s even possible. In your case it’s possible to just not add a Y coordinate since it’s just a transformation of the X coordinate. But in general, a transform field might rely on some field that you don’t use anywhere else - in that case, the point draw tool cannot do anything because it might break the plot.
Your particular case can be made a special case within the tool itself, although I’m not sure if it makes sense.
Since you don’t really want a point draw tool, I think it makes sense to create a custom tool in your case that already knows about the target function.

You also mention that PointDrawTool doesn’t work with Pandas DataFrames. But your code uses a data frame, and it works just fine. What did you mean here?

Thank you for the thorough analysis and feedback. It is appreciated.

The issue with the PointDrawTool and Pandas DataFrames was in the context of the data used for the markers (circle) that are transformed via the interpolator and moved around interactively.

It is correct that a dataframe was used for the constraining curves in the example, but the source for the marker was based on a dictionary.

Thanks again.

Ah, I see. Yes, I can reproduce it.

@Bryan Seems like Bokeh cannot accept patches from clients that contain typed arrays because JSON.stringify(new Float64Array([1])) results in {"0": 1} and that’s not accepted as a column value for a ColumnDataSource.
Sounds like something that should’ve been reported before but I couldn’t find anything. Any thoughts?

AFAIK it is true typed array columns can only successfully flow from Python to JS and and not vice-versa. But no one has ever raised a concrete problem in relation to that until now, so I don’t think there was ever an issue made. (In the server case, CDS data updates typically happen on the server.)