Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for FigureWidget events #7654

Merged
merged 8 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Loading