Skip to content

Commit

Permalink
Add support for FigureWidget events (#7654)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Jan 22, 2025
1 parent 663df35 commit 46489e3
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 17 deletions.
45 changes: 45 additions & 0 deletions panel/models/plotly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface PlotlyHTMLElement extends HTMLDivElement {
on(event: "plotly_relayouting", callback: (eventData: any) => void): void
on(event: "plotly_restyle", callback: (eventData: any) => void): void
on(event: "plotly_click", callback: (eventData: any) => void): void
on(event: "plotly_doubleclick", callback: (eventData: any) => void): void
on(event: "plotly_hover", callback: (eventData: any) => void): void
on(event: "plotly_clickannotation", callback: (eventData: any) => void): void
on(event: "plotly_selected", callback: (eventData: any) => void): void
Expand All @@ -55,6 +56,44 @@ const filterEventData = (gd: any, eventData: any, event: string) => {
return null
}

const event_obj = eventData.event
if (event_obj !== undefined) {
filteredEventData.device_state = {
// Keyboard modifiers
alt: event_obj.altKey,
ctrl: event_obj.ctrlKey,
meta: event_obj.metaKey,
shift: event_obj.shiftKey,
// Mouse buttons
button: event_obj.button,
buttons: event_obj.buttons,
}
}

let selectorObject
if (eventData.hasOwnProperty("range")) {
// Box selection
selectorObject = {
type: "box",
selector_state: {
xrange: eventData.range.x,
yrange: eventData.range.y,
},
}
} else if (eventData.hasOwnProperty("lassoPoints")) {
// Lasso selection
selectorObject = {
type: "lasso",
selector_state: {
xs: eventData.lassoPoints.x,
ys: eventData.lassoPoints.y,
},
}
} else {
selectorObject = null
}
filteredEventData.selector = selectorObject

/*
* remove `data`, `layout`, `xaxis`, etc
* objects from the event data since they're so big
Expand Down Expand Up @@ -299,6 +338,12 @@ export class PlotlyPlotView extends HTMLBoxView {
this.model.trigger_event(new PlotlyEvent({type: "click", data}))
})

// - plotly_doubleclick
this.container.on("plotly_doubleclick", (eventData: any) => {
const data = filterEventData(this.container, eventData, "click")
this.model.trigger_event(new PlotlyEvent({type: "doubleclick", data}))
})

// - plotly_hover
this.container.on("plotly_hover", (eventData: any) => {
const data = filterEventData(this.container, eventData, "hover")
Expand Down
87 changes: 81 additions & 6 deletions panel/pane/plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class Plotly(ModelPane):

click_data = param.Dict(doc="Click event data from `plotly_click` event.")

doubleclick_data = param.Dict(doc="Click event data from `plotly_doubleclick` event.")

clickannotation_data = param.Dict(doc="Clickannotation event data from `plotly_clickannotation` event.")

config = param.Dict(nested_refs=True, doc="""
Expand Down Expand Up @@ -88,8 +90,13 @@ class Plotly(ModelPane):
_updates: ClassVar[bool] = True

_rename: ClassVar[Mapping[str, str | None]] = {
'link_figure': None, 'object': None, 'click_data': None, 'clickannotation_data': None,
'hover_data': None, 'selected_data': None
'link_figure': None,
'object': None,
'doubleclick_data': None,
'click_data': None,
'clickannotation_data': None,
'hover_data': None,
'selected_data': None
}

@classmethod
Expand All @@ -106,7 +113,7 @@ def __init__(self, object=None, **params):

def _to_figure(self, obj):
import plotly.graph_objs as go
if isinstance(obj, go.Figure):
if isinstance(obj, (go.Figure, go.FigureWidget)):
return obj
elif isinstance(obj, dict):
data, layout = obj['data'], obj['layout']
Expand Down Expand Up @@ -158,8 +165,8 @@ def _update_figure(self):
# we don't interfere with subclasses that override these methods.
fig = self.object
fig._send_addTraces_msg = lambda *_, **__: self._update_from_figure('add')
fig._send_moveTraces_msg = lambda *_, **__: self._update_from_figure('move')
fig._send_deleteTraces_msg = lambda *_, **__: self._update_from_figure('delete')
fig._send_moveTraces_msg = lambda *_, **__: self._update_from_figure('move')
fig._send_restyle_msg = self._send_restyle_msg
fig._send_relayout_msg = self._send_relayout_msg
fig._send_update_msg = self._send_update_msg
Expand Down Expand Up @@ -322,11 +329,79 @@ def _get_model(

def _process_event(self, event):
etype = event.data['type']
data = event.data['data']
pname = f'{etype}_data'
if getattr(self, pname) == event.data['data']:
if getattr(self, pname) == data:
self.param.trigger(pname)
else:
self.param.update(**{pname: event.data['data']})
self.param.update(**{pname: data})
if data is None or not hasattr(self.object, '_handler_js2py_pointsCallback'):
return

points = data['points']
num_points = len(points)

has_nested_point_objects = True
for point_obj in points:
has_nested_point_objects = has_nested_point_objects and 'pointNumbers' in point_obj
if not has_nested_point_objects:
break

num_point_numbers = num_points
if has_nested_point_objects:
num_point_numbers = 0
for point_obj in points:
num_point_numbers += len(point_obj['pointNumbers'])

points_object = {
'trace_indexes': [],
'point_indexes': [],
'xs': [],
'ys': [],
}

# Add z if present
has_z = points[0] is not None and 'z' in points[0]
if has_z:
points_object['zs'] = []

if has_nested_point_objects:
for point_obj in points:
for i in range(len(point_obj['pointNumbers'])):
points_object['point_indexes'].append(point_obj['pointNumbers'][i])
points_object['xs'].append(point_obj['x'])
points_object['ys'].append(point_obj['y'])
points_object['trace_indexes'].append(point_obj['curveNumber'])
if has_z and 'z' in point_obj:
points_object['zs'].append(point_obj['z'])

single_trace = True
for i in range(1, num_point_numbers):
single_trace = single_trace and (points_object['trace_indexes'][i - 1] == points_object['trace_indexes'][i])
if not single_trace:
break

if single_trace:
points_object['point_indexes'].sort()
else:
for point_obj in points:
points_object['trace_indexes'].append(point_obj['curveNumber'])
points_object['point_indexes'].append(point_obj['pointNumber'])
points_object['xs'].append(point_obj['x'])
points_object['ys'].append(point_obj['y'])
if has_z and 'z' in point_obj:
points_object['zs'].append(point_obj['z'])

self.object._handler_js2py_pointsCallback(
{
"new": dict(
event_type=f'plotly_{etype}',
points=points_object,
selector=data.get('selector', None),
device_state=data.get('device_state', None)
)
}
)

def _update(self, ref: str, model: Model) -> None:
if self.object is None:
Expand Down
93 changes: 82 additions & 11 deletions panel/tests/ui/pane/test_plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ def plotly_2d_plot():
plot_2d = Plotly({'data': [trace], 'layout': {'width': 350}})
return plot_2d

@pytest.fixture
def plotly_2d_figure_widget():
trace = go.Scatter(x=[0, 1], y=[2, 3], uid='Test')
plot_2d = go.FigureWidget(data=[trace])
return plot_2d

@pytest.fixture
def plotly_3d_plot():
xx = np.linspace(-3.5, 3.5, 100)
Expand Down Expand Up @@ -135,15 +141,26 @@ def test_plotly_hover_data(page, plotly_2d_plot):
point = plotly_plot.locator('g.points path.point').nth(0)
point.hover(force=True)

wait_until(lambda: {
'points': [{
'curveNumber': 0,
'pointIndex': 0,
'pointNumber': 0,
'x': 0,
'y': 2
}]
} in hover_data, page)
def check_hover():
assert plotly_2d_plot.hover_data == {
'selector': None,
'device_state': {
'alt': False,
'button': 0,
'buttons': 0,
'ctrl': False,
'meta': False,
'shift': False,
},
'points': [{
'curveNumber': 0,
'pointIndex': 0,
'pointNumber': 0,
'x': 0,
'y': 2
}]
}
wait_until(check_hover, page)

# Hover somewhere else
plot = page.locator('.js-plotly-plot .plot-container.plotly g.scatterlayer')
Expand All @@ -164,7 +181,16 @@ def test_plotly_click_data(page, plotly_2d_plot):
point.click(force=True)

def check_click(i=i):
return plotly_2d_plot.click_data == {
assert plotly_2d_plot.click_data == {
'selector': None,
'device_state': {
'alt': False,
'button': 0,
'buttons': 1,
'ctrl': False,
'meta': False,
'shift': False,
},
'points': [{
'curveNumber': 0,
'pointIndex': i,
Expand All @@ -177,6 +203,38 @@ def check_click(i=i):
time.sleep(0.2)


def test_plotly_click_data_figure_widget(page, plotly_2d_figure_widget):
fig = go.FigureWidget(plotly_2d_figure_widget)
serve_component(page, fig)

trace = list(fig.select_traces())[0]

events = []
trace.on_click(lambda a, b, c: events.append((a, b, c)))

plotly_plot = page.locator('.js-plotly-plot .plot-container.plotly')
expect(plotly_plot).to_have_count(1)

# Select and click on points
for i in range(2):
point = page.locator('.js-plotly-plot .plot-container.plotly path.point').nth(i)
point.click(force=True)

def check_click(i=i):
if len(events) < (i+1):
return False
click_trace, points, device_state = events[i]
assert click_trace is trace
assert points.xs == [0+i]
assert points.ys == [2+i]
assert not device_state.ctrl
assert not device_state.alt
assert not device_state.shift
assert not device_state.meta
wait_until(check_click, page)
time.sleep(0.2)


def test_plotly_select_data(page, plotly_2d_plot):
serve_component(page, plotly_2d_plot)

Expand Down Expand Up @@ -223,4 +281,17 @@ def test_plotly_img_plot(page, plotly_img_plot):
point = plotly_plot.locator('image')
point.hover(force=True)

wait_until(lambda: plotly_img_plot.hover_data == {'points': [{'curveNumber': 0, 'x': 15, 'y': 3, 'colormodel': 'rgb'}]}, page)
def check_hover():
assert plotly_img_plot.hover_data == {
'selector': None,
'device_state': {
'alt': False,
'button': 0,
'buttons': 0,
'ctrl': False,
'meta': False,
'shift': False,
},
'points': [{'curveNumber': 0, 'x': 15, 'y': 3, 'colormodel': 'rgb'}]
}
wait_until(check_hover, page)

0 comments on commit 46489e3

Please sign in to comment.